Compare commits

..

13 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
24ce23fa12 [PowerScripts] Decouple script id from folder + add trust-on-first-use gate
Two prototype improvements toward a shareable, safe script catalogue:

Catalogue-readiness (id decoupled from folder):
- Script `id` is now the portable identity; the id-must-equal-folder-name
  rule is removed so a shared/downloaded script keeps its id in any folder.
- Registry enforces id uniqueness across the catalogue (duplicate id is
  reported and skipped rather than silently shadowed).
- Manifest gains optional provenance fields: publisher, version, source.

Capability safety (trust-on-first-use):
- New ScriptIntegrity content hash (SHA-256 over entry body + kind +
  declared capabilities) and a persisted TrustStore (trust.json).
- Host `run` now gates every execution (the single choke point for context
  menu, KBM and agents): untrusted scripts prompt a native consent dialog
  showing name/publisher/source/capabilities/path; editing the body or
  escalating capabilities invalidates trust and re-prompts.
- `--no-consent` / POWERSCRIPTS_NO_CONSENT refuses instead of prompting
  (for non-interactive/agent callers); new `trust list|approve|revoke`
  subcommands; `list --json` exposes a `trusted` flag.
- Settings page shows a read-only Trust status row per script.

Tests: id decoupling, duplicate-id rejection, integrity stability/
invalidation, and trust-store round-trip (16/16 Core tests pass).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 16:53:48 +08:00
Muyuan Li (from Dev Box)
6d4a1dee6e [PowerScripts] Make the new KBM editor's PowerScript action e2e-ready
The new Keyboard Manager editor (KeyboardManagerEditorUI) already exposes a
PowerScript action, but on a Debug build it couldn't locate PowerScripts.Host.exe
(it isn't copied next to the editor), so the picker came up empty.

- PowerScriptsCatalog.ResolveHostPath: add the same dev-bin fallback the Settings
  view-model uses (walk up from the editor's base dir and probe
  src\modules\PowerScripts\PowerScripts.Host\bin\{Debug,Release}). The editor can
  now enumerate system scripts and resolve the host path for the saved RunProgram
  mapping in a dev build.
- Add kbm-e2e.ps1: a self-contained end-to-end helper that forces the new editor
  (useNewEditor=true), opens it to assign a hotkey to a system PowerScript, then
  runs KeyboardManagerEngine standalone so the hotkey actually fires
  Host.exe run <id> — no full runner required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 11:38:48 +08:00
Muyuan Li (from Dev Box)
cd327dda07 [PowerScripts] Make Settings page a read-only script catalogue
The manifest.json is the single source of truth for a script's trigger
extensions, surfaces and capabilities. Drop the in-Settings extensions editor
(which only rewrote the manifest via the host) and instead show that
information read-only, so the UI reflects the manifest rather than duplicating
authoring of it.

- PowerScriptListItem: replace the editable ExtensionsText with read-only
  display projections (ExtensionsDisplay/SurfacesDisplay/CapabilitiesDisplay/
  RuntimeDisplay); surface Runtime/Surfaces/Capabilities from list --json.
- PowerScriptsPage.xaml: each script expander now lists Trigger file types
  (file scripts), Runtime, Surfaces and Capabilities as read-only rows.
- Remove SetScriptExtensions / ApplyExtensionsButton_Click. The host
  set-extensions command remains as a CLI/agent capability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 11:08:27 +08:00
Muyuan Li (from Dev Box)
54bd07c08d [PowerScripts] Add Win11 modern context-menu handler (IExplorerCommand)
Legacy registry verbs only appear under "Show more options" on Windows 11.
This adds a self-contained IExplorerCommand COM server (sparse MSIX package)
that surfaces a top-level "PowerScript" entry with a dynamic submenu of the
file scripts matching the current selection.

- PowerScripts.Host: new `shell-menu --files` command emitting tab-separated
  id/name lines for matching file scripts (no JSON parser needed in native code).
- PowerScriptsContextMenu: WRL ClassicCom DLL (dllmain.cpp, dll.def) with a
  top-level command (GetState runs Host shell-menu, caches matches, hides when
  none), an IEnumExplorerCommand enumerator, and per-script items whose Invoke
  runs `Host run <id> --files <path>`. Host located next to the DLL.
- AppxManifest.xml registers the verb (ItemType Type="*", runtime visibility),
  build.cmd compiles via cl.exe, register.ps1 builds+publishes Host+deploys+
  registers the unsigned package (Add-AppxPackage -Register, Developer Mode).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 10:40:55 +08:00
Muyuan Li (from Dev Box)
35ccc7658d PowerScripts: let users edit a file script's trigger extensions in Settings
Add a per-script "Trigger on file types" editor (a SettingsExpander with
an editable extensions box + Apply) on the PowerScripts page for file
scripts. Applying calls a new host command, set-extensions <id> --ext
<.md .txt ...>, which rewrites the manifest's input.extensions via the
shared serializer, then re-registers the Explorer right-click submenu
(uninstall old verbs first so a changed extension leaves nothing stale).
list --json now surfaces input.extensions so the box shows current values.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 16:03:42 +08:00
Muyuan Li (from Dev Box)
b58b2f1a4c PowerScripts: locate the Host exe from in-repo dev builds
The Settings page lists scripts by shelling out to PowerScripts.Host.exe,
but a dev build never copies the Host next to Settings, so the list was
always empty even when the default scripts folder had scripts. Walk up
from the Settings base directory and probe the Host project's bin output
(Debug/Release) as a fallback, in addition to the existing next-to-exe
and %LOCALAPPDATA% locations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 14:49:17 +08:00
Muyuan Li (from Dev Box)
0188c1ac69 PowerScripts: let users choose the scripts folder from Settings
Add a "Scripts folder" card to the PowerScripts page with Browse/Reset.
The chosen path is persisted to the shared config.json, and Core's
ResolveScriptsRoot now reads it (explicit > env > config > default) so
every surface (Settings list, Explorer context menu, KBM run) honors the
same folder. Selecting a folder reloads the list and re-registers the
context-menu entries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 11:39:08 +08:00
Muyuan Li (from Dev Box)
11bda2709b PowerScripts: add ModuleTitle/Description strings to fix blank Dashboard
The Dashboard builds every module tile via resourceLoader.GetString of
the module's ModuleTitle key, which throws COMException "NamedResource
Not Found" for a missing key and aborts BuildModuleList, blanking the
Home page. Add the PowerScripts.ModuleTitle/ModuleDescription resources.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 11:38:57 +08:00
Muyuan Li (from Dev Box)
a618b2f2f9 PowerScripts: update README with implemented-surface table and e2e demo
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:30:43 +08:00
Muyuan Li (from Dev Box)
3cdbca3fa6 PowerScripts: add Settings module page listing scripts + enable toggle
- Add ModuleType.PowerScripts and Enabled.PowerScripts plumbing (EnabledModules,
  ModuleHelper, ModuleGpoHelper, App.GetPage)
- Add PowerScripts Settings nav item + page (NavigablePage) that lists installed
  scripts via 'PowerScripts.Host.exe list --json' and shows an enable toggle
- Enable toggle wires the Explorer context menu directly (Host shell-install/
  shell-uninstall), so the prototype is functional without a runner module DLL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:28:58 +08:00
Muyuan Li (from Dev Box)
be711d12bf PowerScripts: add 'PowerScript' action to Keyboard Manager editor
Adds a new 'PowerScript' action type in the KBM editor's mapping control. The
picker lists system PowerScripts (via PowerScripts.Host.exe list --json) and saves
an ordinary RunProgram mapping invoking 'Host.exe run <id>', so a hotkey can launch
a PowerScript. Editor stays decoupled from PowerScripts assemblies by shelling out
to the Host CLI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:15:53 +08:00
Muyuan Li (from Dev Box)
29ca6328f9 PowerScripts: add convert_md_to_txt + volume_up samples and context-menu registration
- Add two e2e sample scripts: convert_md_to_txt (file/.md) and volume_up (system)
- Add Host shell-install/shell-uninstall: registry-driven 'PowerScript' cascading
  submenu under SystemFileAssociations\\<ext>\\shell, one sub-verb per matching script
- Switch PowerScripts.Host TFM to net10.0-windows for registry access

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 15:50:41 +08:00
Muyuan Li (from Dev Box)
af2c3c61cd [PowerScripts] Add prototype module: core, host CLI, samples, tests
Introduces a prototype of the PowerScripts module (write a script once,
surface it across PowerToys). Includes:
- PowerScripts.Core: manifest schema, validation, registry, executor
- PowerScripts.Host: list/run/kbm CLI (shared invocation + KBM RunProgram mapping)
- PowerScripts.Core.Tests: MSTest unit tests (9 passing)
- Two sample scripts (system-snapshot, sha256-checksum) and README

Surfaces prioritized: Explorer right-click + Keyboard Manager. Build is
isolated from the repo (local Directory.Build.props/Packages/nuget.config)
while prototyping; remove to adopt standard PowerToys build rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 15:31:41 +08:00
131 changed files with 4256 additions and 3423 deletions

View File

@@ -135,7 +135,6 @@ BITMAPINFO
BITMAPINFOHEADER
BITSPERPEL
BITSPIXEL
Blackmagic
bla
BLENDFUNCTION
blittable
@@ -540,7 +539,6 @@ EXTRINSICPROPERTIES
eyetracker
FANCYZONESDRAWLAYOUTTEST
FANCYZONESEDITOR
Fairlight
FARPROC
fdw
fdx
@@ -2180,7 +2178,6 @@ xclip
xcopy
xdf
xfd
xhair
xmp
Xoshiro
xsi

View File

@@ -30,12 +30,6 @@ These are auto-applied based on file location:
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
## Shortcut Guide V2 Manifests
When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:
- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
## Detailed Documentation
- [Architecture](../doc/devdocs/core/architecture.md)

View File

@@ -73,13 +73,6 @@ jobs:
const itemType = issue.pull_request ? 'Pull request' : 'Issue';
// Skip pull requests that already have labels applied.
if (issue.pull_request && issue.labels && issue.labels.length > 0) {
const existingLabels = issue.labels.map(l => l.name).join(', ');
console.log(`${itemType} #${issueNumber} already has labels (${existingLabels}); skipping.`);
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';

View File

@@ -25,25 +25,6 @@
<MSBuild Projects="$(MSBuildProjectFullPath)" Targets="Restore" Properties="RestoreInProgress=true" BuildInParallel="false" />
</Target>
<!--
The Microsoft.Web.WebView2 package's managed .targets unconditionally references the WPF
wrapper (Microsoft.Web.WebView2.Wpf.dll) for every non-WinRT .NET project. That wrapper
depends on WPF's WindowsBase, which only ships in the WPF profile of the WindowsDesktop
reference pack. WinForms-only or plain projects therefore resolve WindowsBase to the
4.0.0.0 facade from Microsoft.NETCore.App, producing an MSB3277 conflict against the
wrapper's 5.0.0.0 reference. A project that doesn't enable WPF can't use the WPF WebView2
control anyway, so drop that unused reference before RAR runs (WPF projects keep it).
WinUI/WinAppSDK projects use the CsWinRT projection and never get this reference, so this
is a no-op for them.
-->
<Target Name="RemoveUnusedWebView2WpfReference"
BeforeTargets="ResolveAssemblyReferences"
Condition="'$(UseWPF)' != 'true'">
<ItemGroup>
<Reference Remove="@(Reference)" Condition="'%(Reference.Filename)' == 'Microsoft.Web.WebView2.Wpf'" />
</ItemGroup>
</Target>
<PropertyGroup Condition="'$(IgnoreExperimentalWarnings)' == 'true'">
<NoWarn>$(NoWarn);CS8305;SA1500;CA1852</NoWarn>
</PropertyGroup>

View File

@@ -26,7 +26,7 @@
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" />
<PackageVersion Include="ControlzEx" Version="6.0.0" />
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
@@ -64,7 +64,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.71.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.71.0-alpha" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="10.0.8" />
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
@@ -76,7 +76,7 @@
This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail.
-->
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1" />
<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" />
@@ -151,4 +151,4 @@
<PackageVersion Include="Microsoft.VariantAssignment.Client" Version="2.4.17140001" />
<PackageVersion Include="Microsoft.VariantAssignment.Contract" Version="3.0.16990001" />
</ItemGroup>
</Project>
</Project>

View File

@@ -12,7 +12,6 @@ This software incorporates material from third parties.
- Peek
- PowerDisplay
- Registry Preview
- ZoomIt
## Utility: Color Picker
@@ -1550,69 +1549,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Utility: ZoomIt
### libwebp
ZoomIt uses libwebp to encode screenshots in the WebP image format.
**Source**: <https://github.com/webmproject/libwebp>
BSD-3-Clause License
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Additional IP Rights Grant (Patents)
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.
## NuGet Packages used by PowerToys
- AdaptiveCards.ObjectModel.WinUI3

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
</packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
</packages>

View File

@@ -29,30 +29,8 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Backdrops;
/// </remarks>
public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
{
/// <summary>
/// Identifies the <see cref="Kind"/> dependency property.
/// </summary>
public static readonly DependencyProperty KindProperty = DependencyProperty.Register(
nameof(Kind),
typeof(DesktopAcrylicKind),
typeof(AlwaysActiveDesktopAcrylicBackdrop),
new PropertyMetadata(DesktopAcrylicKind.Default, OnKindChanged));
private readonly Dictionary<ICompositionSupportsSystemBackdrop, BackdropTarget> _targets = new();
/// <summary>
/// Gets or sets the desktop acrylic material variant to render. Defaults to
/// <see cref="DesktopAcrylicKind.Default"/> (the standard, more opaque
/// acrylic); <see cref="DesktopAcrylicKind.Thin"/> renders a lighter, more
/// translucent material and <see cref="DesktopAcrylicKind.Base"/> the base
/// material. Changing this updates any live backdrop targets immediately.
/// </summary>
public DesktopAcrylicKind Kind
{
get => (DesktopAcrylicKind)GetValue(KindProperty);
set => SetValue(KindProperty, value);
}
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
{
base.OnTargetConnected(connectedTarget, xamlRoot);
@@ -63,10 +41,7 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
Theme = ResolveTheme(xamlRoot),
};
var controller = new DesktopAcrylicController
{
Kind = Kind,
};
var controller = new DesktopAcrylicController();
controller.SetSystemBackdropConfiguration(configuration);
controller.AddSystemBackdropTarget(connectedTarget);
@@ -95,17 +70,6 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
}
}
private static void OnKindChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var self = (AlwaysActiveDesktopAcrylicBackdrop)d;
var kind = (DesktopAcrylicKind)e.NewValue;
foreach (var target in self._targets.Values)
{
target.Controller.Kind = kind;
}
}
private static SystemBackdropTheme ResolveTheme(XamlRoot xamlRoot) =>
xamlRoot.Content is FrameworkElement rootElement
? rootElement.ActualTheme switch

View File

@@ -1,467 +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 CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Animations;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Hosting;
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A floating, self-animating "pseudo window" surface for transient PowerToys
/// overlays (toasts, banners, indicators). It looks like a control but behaves
/// like a lightweight window: it provides the PowerToys-standard chrome — 1 px
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius, a
/// <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop — and owns
/// its own show/hide animations.
/// </summary>
/// <remarks>
/// <para>Designed to be declared as the root content of a
/// <see cref="TransparentWindow"/>, which stays animation-agnostic. Call
/// <see cref="SubscribeTo"/> once (e.g. from the hosting window's constructor)
/// to wire this surface to the window's <see cref="TransparentWindow.Showing"/> /
/// <see cref="TransparentWindow.Hiding"/> events. From then on the surface
/// animates itself in/out whenever the window is shown or hidden, and uses the
/// <see cref="TransparentWindow.Hiding"/> deferral to keep the window visible
/// until its out-animation finishes.</para>
/// <para>The show transition comes from the window's
/// <see cref="TransparentWindow.Show(Transition)"/> call (or from
/// <see cref="ShowTransition"/> when shown without one); the hide transition
/// always comes from <see cref="HideTransition"/>. Animations target the
/// surface itself, so the entire surface (border, acrylic, shadow, inner
/// content) animates as one. Apps that want a different look supply their own
/// <c>Style TargetType="TransientSurface"</c> in resources — the standard WinUI
/// restyle path.</para>
/// </remarks>
public sealed partial class TransientSurface : ContentControl
{
private const float ShadowDepth = 32f;
private const double SlideInOffset = 24;
private const double SlideOutOffset = 12;
// "Pop" transition: scale between 96% and 100% (a subtle 4% grow). Following
// Fluent motion guidance the scale uses a decelerate (EaseOut) curve; the
// fade is kept fast so the surface reads as an instant, light pop.
//
// The fade must run at least as long as the scale: if the scale outlasted the
// fade, the surface would reach full opacity while still visibly growing,
// which reads as a "resize" rather than a pop. Keeping the fade >= the scale
// hides the growth under the opacity ramp, so by the time it is fully opaque
// it is already at 100% size.
private const float PopScaleFrom = 0.96f;
private const double PopFadeShowMs = 180;
private const double PopScaleShowMs = 150;
private const double PopFadeHideMs = 120;
private const double PopScaleHideMs = 120;
public static readonly DependencyProperty ShowTransitionProperty = DependencyProperty.Register(
nameof(ShowTransition),
typeof(Transition),
typeof(TransientSurface),
new PropertyMetadata(Transition.None, OnTransitionChanged));
public static readonly DependencyProperty HideTransitionProperty = DependencyProperty.Register(
nameof(HideTransition),
typeof(Transition),
typeof(TransientSurface),
new PropertyMetadata(Transition.None, OnTransitionChanged));
public static readonly DependencyProperty AcrylicKindProperty = DependencyProperty.Register(
nameof(AcrylicKind),
typeof(DesktopAcrylicKind),
typeof(TransientSurface),
new PropertyMetadata(DesktopAcrylicKind.Thin));
private readonly DispatcherQueueTimer _hideCompletedTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly ImplicitAnimationSet _noAnimations = new();
private ImplicitAnimationSet _showAnimations = new();
private ImplicitAnimationSet _hideAnimations = new();
private bool _hasCustomShowAnimations;
private bool _hasCustomHideAnimations;
private Action? _abandonPendingHide;
public TransientSurface()
{
DefaultStyleKey = typeof(TransientSurface);
RebuildDefaultAnimations();
// Pin the scale center to the surface's center so the "Pop" transition
// grows/shrinks from the middle, not the top-left corner. An expression
// animation bound to the visual's own size keeps the center correct from
// the very first frame (a SizeChanged handler would race the show
// animation and let the first pop scale from 0,0).
PinScaleCenter();
// Start hidden so the first Show() animates in from the configured pose.
Visibility = Visibility.Collapsed;
}
/// <summary>
/// Raised after <see cref="Hide"/> once the longest animation in
/// <see cref="HideAnimations"/> (delay + duration) has completed.
/// </summary>
public event EventHandler? HideCompleted;
/// <summary>
/// Gets or sets the transition played when the surface is shown without an
/// explicit one (see <see cref="Show()"/>). Defaults to
/// <see cref="Transition.None"/>, which plays no animation at all (the
/// surface appears instantly); a directional value adds a fade plus a slide
/// in from that edge, and <see cref="Transition.Pop"/> a fade plus a subtle
/// scale-up. Changing this regenerates the default <see cref="ShowAnimations"/>
/// unless it has been set explicitly.
/// </summary>
public Transition ShowTransition
{
get => (Transition)GetValue(ShowTransitionProperty);
set => SetValue(ShowTransitionProperty, value);
}
/// <summary>
/// Gets or sets the transition played when the surface is hidden (see
/// <see cref="Hide"/>). Defaults to <see cref="Transition.None"/>, which
/// plays no animation at all (the surface disappears instantly); a
/// directional value adds a fade plus a slide out toward that edge, and
/// <see cref="Transition.Pop"/> a fade plus a subtle scale-down. Changing
/// this regenerates the default <see cref="HideAnimations"/> unless it has
/// been set explicitly.
/// </summary>
public Transition HideTransition
{
get => (Transition)GetValue(HideTransitionProperty);
set => SetValue(HideTransitionProperty, value);
}
/// <summary>
/// Gets or sets the desktop acrylic material variant painted behind the
/// surface. Defaults to <see cref="DesktopAcrylicKind.Thin"/> (a lighter,
/// more translucent material); set <see cref="DesktopAcrylicKind.Default"/>
/// for the standard, more opaque acrylic or <see cref="DesktopAcrylicKind.Base"/>
/// for the base material. Has no effect when a custom template without the
/// default acrylic backdrop is applied.
/// </summary>
public DesktopAcrylicKind AcrylicKind
{
get => (DesktopAcrylicKind)GetValue(AcrylicKindProperty);
set => SetValue(AcrylicKindProperty, value);
}
/// <summary>
/// Gets or sets the animations played when <see cref="Show()"/> flips the
/// surface to <see cref="Visibility.Visible"/>. Defaults to the animation
/// derived from <see cref="ShowTransition"/>. Assigning a value marks the set
/// as custom so <see cref="ShowTransition"/> no longer overwrites it.
/// </summary>
public ImplicitAnimationSet ShowAnimations
{
get => _showAnimations;
set
{
_showAnimations = value ?? new ImplicitAnimationSet();
_hasCustomShowAnimations = true;
}
}
/// <summary>
/// Gets or sets the animations played when <see cref="Hide"/> flips the
/// surface to <see cref="Visibility.Collapsed"/>. Defaults to the animation
/// derived from <see cref="HideTransition"/>. Assigning a value marks the set
/// as custom so <see cref="HideTransition"/> no longer overwrites it.
/// </summary>
public ImplicitAnimationSet HideAnimations
{
get => _hideAnimations;
set
{
_hideAnimations = value ?? new ImplicitAnimationSet();
_hasCustomHideAnimations = true;
}
}
/// <summary>
/// Wires this surface to a hosting <see cref="TransparentWindow"/> so it
/// animates itself in and out in response to the window's
/// <see cref="TransparentWindow.Showing"/> / <see cref="TransparentWindow.Hiding"/>
/// events. Call this once after the surface has been set as (or placed within)
/// the window's content.
/// </summary>
/// <param name="host">The window whose show/hide transitions drive this surface.</param>
public void SubscribeTo(TransparentWindow host)
{
ArgumentNullException.ThrowIfNull(host);
host.Showing += OnHostShowing;
host.Hiding += OnHostHiding;
}
/// <summary>
/// Resets the surface to its hidden pose and flips it to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays,
/// using <paramref name="transition"/> as the show transition.
/// </summary>
/// <param name="transition">The transition to play when showing.</param>
public void Show(Transition transition)
{
ShowTransition = transition;
Show();
}
/// <summary>
/// Resets the surface to its hidden pose and flips it to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
/// Repeated calls re-trigger the show animation cleanly and cancel any
/// pending <see cref="HideCompleted"/> notification.
/// </summary>
public void Show()
{
_hideCompletedTimer.Stop();
// If a hide from a previous cycle is still in flight, abandon it: drop its
// pending HideCompleted handler so the outstanding deferral is never
// completed. We are showing again, so the host must keep the window
// visible instead of later hiding it for this interrupted cycle.
_abandonPendingHide?.Invoke();
_abandonPendingHide = null;
// Attach the show animation and detach any hide animation: when Show() is
// called while the surface is still visible, the Collapsed -> Visible
// restart below would otherwise play the hide animation (a fade/scale out)
// immediately before the show, producing a visible flash. The real hide
// animation is re-attached just-in-time in Hide().
Implicit.SetShowAnimations(this, _showAnimations);
Implicit.SetHideAnimations(this, _noAnimations);
// Reset to the hidden pose so the show animation always animates from the
// configured starting frame.
Visibility = Visibility.Collapsed;
Visibility = Visibility.Visible;
}
/// <summary>
/// Flips the surface to <see cref="Visibility.Collapsed"/> so
/// <see cref="HideAnimations"/> plays, then raises <see cref="HideCompleted"/>
/// once the longest animation in <see cref="HideAnimations"/> (delay +
/// duration) has completed.
/// </summary>
public void Hide()
{
// Attach the hide animation just before collapsing (Show() detaches it to
// avoid a flash when re-showing an already-visible surface).
Implicit.SetHideAnimations(this, _hideAnimations);
Visibility = Visibility.Collapsed;
_hideCompletedTimer.Debounce(
() => HideCompleted?.Invoke(this, EventArgs.Empty),
interval: GetAnimationSetTotalDuration(_hideAnimations),
immediate: false);
}
private static void OnTransitionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((TransientSurface)d).RebuildDefaultAnimations();
}
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
{
TimeSpan longest = TimeSpan.Zero;
foreach (var animation in set)
{
if (animation is Animation anim)
{
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
if (total > longest)
{
longest = total;
}
}
}
return longest;
}
private static (string? ShowFrom, string? HideTo) GetSlideOffsets(Transition transition) => transition switch
{
Transition.Bottom => ($"0,{SlideInOffset},{ShadowDepth}", $"0,{SlideOutOffset},{ShadowDepth}"),
Transition.Top => ($"0,{-SlideInOffset},{ShadowDepth}", $"0,{-SlideOutOffset},{ShadowDepth}"),
Transition.Left => ($"{-SlideInOffset},0,{ShadowDepth}", $"{-SlideOutOffset},0,{ShadowDepth}"),
Transition.Right => ($"{SlideInOffset},0,{ShadowDepth}", $"{SlideOutOffset},0,{ShadowDepth}"),
_ => (null, null),
};
private void OnHostShowing(TransparentWindow sender, ShowingEventArgs e)
{
if (e.Transition is Transition transition)
{
Show(transition);
}
else
{
Show();
}
}
private void OnHostHiding(TransparentWindow sender, HidingEventArgs e)
{
// Take a deferral so the host keeps its window visible until our
// out-animation has finished, then complete it from HideCompleted.
var deferral = e.GetDeferral();
void OnHideCompleted(object? s, EventArgs args)
{
HideCompleted -= OnHideCompleted;
_abandonPendingHide = null;
deferral.Complete();
}
// Let a subsequent Show() cancel this hide cleanly: unsubscribe the
// handler so the deferral is never completed (the window stays visible)
// rather than firing AppWindow.Hide for an interrupted cycle.
_abandonPendingHide = () => HideCompleted -= OnHideCompleted;
HideCompleted += OnHideCompleted;
Hide();
}
private void RebuildDefaultAnimations()
{
if (!_hasCustomShowAnimations)
{
_showAnimations = BuildShowAnimations(ShowTransition);
}
if (!_hasCustomHideAnimations)
{
_hideAnimations = BuildHideAnimations(HideTransition);
}
}
private void PinScaleCenter()
{
var visual = ElementCompositionPreview.GetElementVisual(this);
var center = visual.Compositor.CreateExpressionAnimation("Vector3(this.Target.Size.X * 0.5, this.Target.Size.Y * 0.5, 0)");
visual.StartAnimation("CenterPoint", center);
}
private static ImplicitAnimationSet BuildShowAnimations(Transition transition)
{
var animations = new ImplicitAnimationSet();
if (transition == Transition.None)
{
// No animation at all.
return animations;
}
if (transition == Transition.Pop)
{
animations.Add(new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(PopFadeShowMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
animations.Add(new ScaleAnimation
{
From = $"{PopScaleFrom},{PopScaleFrom},1",
To = "1,1,1",
Duration = TimeSpan.FromMilliseconds(PopScaleShowMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
return animations;
}
var (slideFrom, _) = GetSlideOffsets(transition);
animations.Add(new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(200),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
animations.Add(new TranslationAnimation
{
From = slideFrom,
To = $"0,0,{ShadowDepth}",
Duration = TimeSpan.FromMilliseconds(250),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
return animations;
}
private static ImplicitAnimationSet BuildHideAnimations(Transition transition)
{
var animations = new ImplicitAnimationSet();
if (transition == Transition.None)
{
// No animation at all.
return animations;
}
if (transition == Transition.Pop)
{
animations.Add(new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(PopFadeHideMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
animations.Add(new ScaleAnimation
{
From = "1,1,1",
To = $"{PopScaleFrom},{PopScaleFrom},1",
Duration = TimeSpan.FromMilliseconds(PopScaleHideMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
return animations;
}
var (_, slideTo) = GetSlideOffsets(transition);
animations.Add(new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
animations.Add(new TranslationAnimation
{
From = $"0,0,{ShadowDepth}",
To = slideTo,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
return animations;
}
}

View File

@@ -1,35 +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.
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A show or hide transition a surface (e.g. <see cref="TransientSurface"/>)
/// plays when it is shown or hidden. The directional values describe an edge —
/// interpreted as <em>in from</em> that edge on show and <em>out toward</em> it
/// on hide — while <see cref="None"/> and <see cref="Pop"/> are non-directional.
/// </summary>
public enum Transition
{
/// <summary>No animation; the surface appears and disappears instantly.</summary>
None,
/// <summary>Slide from the left edge (in from on show, out toward on hide).</summary>
Left,
/// <summary>Slide from the top edge (in from on show, out toward on hide).</summary>
Top,
/// <summary>Slide from the right edge (in from on show, out toward on hide).</summary>
Right,
/// <summary>Slide from the bottom edge (in from on show, out toward on hide).</summary>
Bottom,
/// <summary>
/// A subtle "pop": a quick fade combined with a small scale between 96% and
/// 100% from the surface's center. Stays in place — no slide.
/// </summary>
Pop,
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A floating "card" surface for transient PowerToys overlays (toasts,
/// banners, indicators). Provides the PowerToys-standard chrome — 1 px
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius,
/// a <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop —
/// via a default <see cref="Microsoft.UI.Xaml.Controls.ControlTemplate"/>.
/// </summary>
/// <remarks>
/// Lives inside a <see cref="Window.TransparentWindow"/>. Apps that want a
/// different look supply their own <c>Style TargetType="TransparentCard"</c>
/// in resources — the standard WinUI restyle path.
/// </remarks>
public sealed partial class TransparentCard : ContentControl
{
public TransparentCard()
{
DefaultStyleKey = typeof(TransparentCard);
}
}

View File

@@ -5,9 +5,9 @@
xmlns:backdrops="using:Microsoft.PowerToys.Common.UI.Controls.Backdrops"
xmlns:local="using:Microsoft.PowerToys.Common.UI.Controls">
<Style BasedOn="{StaticResource DefaultTransientSurfaceStyle}" TargetType="local:TransientSurface" />
<Style BasedOn="{StaticResource DefaultTransparentCardStyle}" TargetType="local:TransparentCard" />
<Style x:Key="DefaultTransientSurfaceStyle" TargetType="local:TransientSurface">
<Style x:Key="DefaultTransparentCardStyle" TargetType="local:TransparentCard">
<Setter Property="BorderBrush" Value="{ThemeResource SurfaceStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
@@ -16,7 +16,7 @@
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:TransientSurface">
<ControlTemplate TargetType="local:TransparentCard">
<Grid
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
@@ -27,7 +27,7 @@
</Grid.Shadow>
<SystemBackdropElement CornerRadius="{TemplateBinding CornerRadius}">
<SystemBackdropElement.SystemBackdrop>
<backdrops:AlwaysActiveDesktopAcrylicBackdrop Kind="{TemplateBinding AcrylicKind}" />
<backdrops:AlwaysActiveDesktopAcrylicBackdrop />
</SystemBackdropElement.SystemBackdrop>
</SystemBackdropElement>
<ContentPresenter
@@ -41,4 +41,5 @@
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -9,7 +9,7 @@ using Microsoft.UI.Windowing;
using Windows.Graphics;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
/// <summary>
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
@@ -187,13 +187,16 @@ public static partial class FlyoutWindowHelper
}
/// <summary>
/// Move and resize <paramref name="window"/> to <paramref name="finalRect"/> (absolute
/// screen physical-pixel coordinates) on <paramref name="targetDisplay"/>. Performs a
/// two-step move that avoids WM_DPICHANGED double-scaling: first a 1×1 teleport into the
/// target display (invisible at that size), then the real position+size while the window
/// is already on that monitor. Skips the teleport when already on the target display.
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
/// rect the effect is invisible). Then sets the real position+size while the window
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
/// handler doesn't fire and overwrite our computed rect.
///
/// Skips the teleport when the window is already on the target display, since there
/// is no boundary to cross.
/// </summary>
public static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
{
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;

View File

@@ -4,6 +4,6 @@
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/ShortcutWithTextLabelControl/ShortcutWithTextLabelControl.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransientSurface/TransientSurface.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransparentCard/TransparentCard.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -0,0 +1,290 @@
// 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.Runtime.InteropServices;
using CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Animations;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Markup;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Reusable transparent host window for transient overlays
/// (toasts, banners, indicators) that should not steal foreground.
/// </summary>
/// <remarks>
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
/// currently hand-roll:</para>
/// <list type="bullet">
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
/// <item>Extend content into the title bar and collapse the title bar.</item>
/// </list>
/// <para>The visible chrome (acrylic + border + corner radius + shadow) lives
/// in a <see cref="TransparentCard"/> that the constructor assigns to
/// <see cref="Microsoft.UI.Xaml.Window.Content"/>. Consumers supply their own
/// UI via <see cref="InnerContent"/> — which is the XAML default-content slot
/// thanks to <see cref="ContentPropertyAttribute"/> — so a derived window can
/// be written as <c>&lt;common:TransparentWindow&gt;&lt;TextBlock/&gt;&lt;/common:TransparentWindow&gt;</c>.</para>
/// <para>Transparency is achieved with a <see cref="TransparentTintBackdrop"/>
/// system backdrop so the area outside the <see cref="TransparentCard"/> is
/// fully see-through. That buffer area is NOT click-through, so consumers
/// should keep it as small as possible (just enough to give the card's
/// shadow + slide animation room to breathe — roughly 24 px on each side).</para>
/// <para><see cref="Show"/> and <see cref="Hide"/> coordinate <c>SW_SHOWNA</c>
/// (no-activate), the <see cref="Microsoft.UI.Xaml.UIElement.Visibility"/>
/// toggle on the card, and a debounced
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> sized from the longest
/// animation in <see cref="HideAnimations"/>. Animations target the card so
/// the entire surface (border, acrylic, shadow, inner content) slides as one.</para>
/// </remarks>
[ContentProperty(Name = nameof(InnerContent))]
public partial class TransparentWindow : WinUIEx.WindowEx
{
private const uint DwmwaColorNone = 0xFFFFFFFE;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwaBorderColor = 34;
private const int DwmwcpDoNotRound = 1;
private const int GwlExStyle = -20;
private const int WsExToolWindow = 0x00000080;
private const int SwShowNa = 8;
private readonly DispatcherQueueTimer _hideCloseTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly nint _hwnd;
private readonly TransparentCard _card;
private ImplicitAnimationSet _showAnimations;
private ImplicitAnimationSet _hideAnimations;
public TransparentWindow()
{
AppWindow.Hide();
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
unsafe
{
uint borderColor = DwmwaColorNone;
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
int cornerPref = DwmwcpDoNotRound;
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
}
ApplyExStyleBit(WsExToolWindow, true);
_showAnimations = BuildDefaultShowAnimations();
_hideAnimations = BuildDefaultHideAnimations();
_card = new TransparentCard();
Content = _card;
SystemBackdrop = new TransparentTintBackdrop();
}
/// <summary>
/// Gets the <see cref="TransparentCard"/> that provides the window's
/// visible chrome (acrylic + border + shadow). Consumers can configure
/// its layout (e.g. <c>HorizontalAlignment</c>, <c>VerticalAlignment</c>,
/// <c>MaxWidth</c>, <c>Margin</c>) to position the card inside the
/// window, or apply a custom <c>Style</c> to change its look.
/// </summary>
public TransparentCard Card => _card;
/// <summary>
/// Gets or sets the visual hosted inside the window's
/// <see cref="TransparentCard"/>. This is the XAML default-content slot:
/// child elements declared between the opening and closing
/// <c>TransparentWindow</c> tags in a derived .xaml are routed here.
/// </summary>
public object? InnerContent
{
get => _card.Content;
set => _card.Content = value;
}
/// <summary>
/// Gets or sets the animations played against
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Show"/>
/// flips it to <see cref="Visibility.Visible"/>. Defaults to a 200 ms
/// fade-in plus a 250 ms slide-up of 24 px.
/// </summary>
public ImplicitAnimationSet ShowAnimations
{
get => _showAnimations;
set => _showAnimations = value ?? new ImplicitAnimationSet();
}
/// <summary>
/// Gets or sets the animations played against
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Hide"/>
/// flips it to <see cref="Visibility.Collapsed"/>. Defaults to a 180 ms
/// fade-out plus a 180 ms slide-down of 12 px.
/// </summary>
public ImplicitAnimationSet HideAnimations
{
get => _hideAnimations;
set => _hideAnimations = value ?? new ImplicitAnimationSet();
}
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and flips
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
/// Repeated calls reset the content to its hidden pose first so the show
/// animation re-triggers cleanly. Any pending hide is cancelled.
/// </summary>
public void Show()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
_hideCloseTimer.Stop();
if (Content is UIElement content)
{
// Re-apply each call so swapping animation collections at
// runtime takes effect on the next show/hide cycle.
Implicit.SetShowAnimations(content, _showAnimations);
Implicit.SetHideAnimations(content, _hideAnimations);
// Reset to the hidden pose so the show animation always
// animates from the configured starting frame.
content.Visibility = Visibility.Collapsed;
}
_ = ShowWindow(_hwnd, SwShowNa);
if (Content is UIElement c2)
{
c2.Visibility = Visibility.Visible;
}
});
}
/// <summary>
/// Flips <see cref="Microsoft.UI.Xaml.Window.Content"/> to
/// <see cref="Visibility.Collapsed"/> so <see cref="HideAnimations"/>
/// plays, then hides the underlying
/// <see cref="Microsoft.UI.Windowing.AppWindow"/> once the longest
/// animation in <see cref="HideAnimations"/> (delay + duration) has
/// completed.
/// </summary>
public void Hide()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
if (Content is UIElement content)
{
content.Visibility = Visibility.Collapsed;
}
_hideCloseTimer.Debounce(
AppWindow.Hide,
interval: GetAnimationSetTotalDuration(_hideAnimations),
immediate: false);
});
}
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
{
TimeSpan longest = TimeSpan.Zero;
foreach (var animation in set)
{
if (animation is Animation anim)
{
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
if (total > longest)
{
longest = total;
}
}
}
return longest;
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)
{
return;
}
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
if (updated != exStyle)
{
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
}
}
private static ImplicitAnimationSet BuildDefaultShowAnimations() => new()
{
new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(200),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
},
new TranslationAnimation
{
From = "0,24,32",
To = "0,0,32",
Duration = TimeSpan.FromMilliseconds(250),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
},
};
private static ImplicitAnimationSet BuildDefaultHideAnimations() => new()
{
new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
},
new TranslationAnimation
{
From = "0,0,32",
To = "0,12,32",
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
},
};
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("dwmapi.dll")]
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
}

View File

@@ -1,61 +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.Threading;
using Deferral = global::Windows.Foundation.Deferral;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Data for <see cref="TransparentWindow.Hiding"/>. Supports deferrals so an
/// animated surface can keep the window visible until its out-animation has
/// finished. If no handler takes a deferral, the window hides immediately.
/// </summary>
public sealed class HidingEventArgs : EventArgs
{
private int _outstanding;
private bool _raised;
private Action? _continuation;
/// <summary>
/// Requests that the window stay visible until the returned deferral is
/// completed. Call <see cref="Deferral.Complete"/> once the out-animation
/// has finished.
/// </summary>
/// <returns>A deferral that must be completed to allow the window to hide.</returns>
public Deferral GetDeferral()
{
Interlocked.Increment(ref _outstanding);
return new Deferral(OnDeferralCompleted);
}
/// <summary>
/// Called by the window after raising the event to register what should run
/// once every outstanding deferral has completed (or immediately if none
/// were taken).
/// </summary>
internal void RunWhenComplete(Action continuation)
{
_continuation = continuation;
_raised = true;
TryComplete();
}
private void OnDeferralCompleted()
{
Interlocked.Decrement(ref _outstanding);
TryComplete();
}
private void TryComplete()
{
if (_raised && Volatile.Read(ref _outstanding) == 0)
{
var continuation = _continuation;
_continuation = null;
continuation?.Invoke();
}
}
}

View File

@@ -1,26 +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;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Data for <see cref="TransparentWindow.Showing"/>. Carries the transition the
/// content should play, or <see langword="null"/> to let the content use its own
/// configured show transition.
/// </summary>
public sealed class ShowingEventArgs : EventArgs
{
public ShowingEventArgs(Transition? transition)
{
Transition = transition;
}
/// <summary>
/// Gets the transition the content should play, or <see langword="null"/> to
/// use the content's own configured show transition.
/// </summary>
public Transition? Transition { get; }
}

View File

@@ -1,164 +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.Runtime.InteropServices;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Windows.Foundation;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Reusable transparent host window for transient overlays
/// (toasts, banners, indicators) that should not steal foreground.
/// </summary>
/// <remarks>
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
/// currently hand-roll:</para>
/// <list type="bullet">
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
/// <item>Extend content into the title bar and collapse the title bar.</item>
/// <item>Apply a <see cref="TransparentTintBackdrop"/> so the HWND is fully
/// see-through and the visible chrome can be drawn by the content.</item>
/// </list>
/// <para>This window is intentionally animation-agnostic: it does not own any
/// chrome or motion. Consumers supply their own content (typically a
/// <see cref="TransientSurface"/>) which draws the acrylic, border, corners and
/// shadow, and animates itself. <see cref="Show()"/> and <see cref="Hide"/>
/// coordinate <c>SW_SHOWNA</c> (no-activate) with the
/// <see cref="Showing"/> / <see cref="Hiding"/> events: a content surface
/// subscribes to those (e.g. via <see cref="TransientSurface.SubscribeTo"/>)
/// and plays its in/out animation. The <see cref="Hiding"/> event supports
/// deferrals, so the underlying
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
/// content has finished animating out. With no listener the window simply shows
/// or hides immediately.</para>
/// </remarks>
public partial class TransparentWindow : WinUIEx.WindowEx
{
private const uint DwmwaColorNone = 0xFFFFFFFE;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwaBorderColor = 34;
private const int DwmwcpDoNotRound = 1;
private const int GwlExStyle = -20;
private const int WsExToolWindow = 0x00000080;
private const int SwShowNa = 8;
private readonly nint _hwnd;
public TransparentWindow()
{
AppWindow.Hide();
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
unsafe
{
uint borderColor = DwmwaColorNone;
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
int cornerPref = DwmwcpDoNotRound;
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
}
ApplyExStyleBit(WsExToolWindow, true);
SystemBackdrop = new TransparentTintBackdrop();
}
/// <summary>
/// Raised (without activation) when <see cref="Show()"/> makes the window
/// visible. A content surface subscribes to this to play its in-animation,
/// using <see cref="ShowingEventArgs.Transition"/>.
/// </summary>
public event TypedEventHandler<TransparentWindow, ShowingEventArgs>? Showing;
/// <summary>
/// Raised when <see cref="Hide"/> begins dismissing the window. A content
/// surface subscribes to this to play its out-animation, taking a deferral
/// (<see cref="HidingEventArgs.GetDeferral"/>) so the underlying window stays
/// visible until the animation completes.
/// </summary>
public event TypedEventHandler<TransparentWindow, HidingEventArgs>? Hiding;
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
/// <see cref="Showing"/> without a transition, so subscribed content animates
/// in using its own configured show transition.
/// </summary>
public void Show() => RaiseShow(null);
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
/// <see cref="Showing"/> so subscribed content animates in using
/// <paramref name="transition"/>, overriding its configured show transition.
/// </summary>
/// <param name="transition">The transition the content should play.</param>
public void Show(Transition transition) => RaiseShow(transition);
private void RaiseShow(Transition? transition)
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
_ = ShowWindow(_hwnd, SwShowNa);
Showing?.Invoke(this, new ShowingEventArgs(transition));
});
}
/// <summary>
/// Raises <see cref="Hiding"/> so subscribed content animates out, then hides
/// the underlying <see cref="Microsoft.UI.Windowing.AppWindow"/> once every
/// deferral taken by a handler has completed (immediately if none were taken).
/// </summary>
public void Hide()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
var args = new HidingEventArgs();
Hiding?.Invoke(this, args);
args.RunWhenComplete(AppWindow.Hide);
});
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)
{
return;
}
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
if (updated != exStyle)
{
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
}
}
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("dwmapi.dll")]
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
}

View File

@@ -38,6 +38,7 @@ namespace ManagedCommon
Workspaces,
GrabAndMove,
ZoomIt,
PowerScripts,
GeneralSettings,
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -373,13 +373,6 @@ static int g_overlayRenderedH = 0;
// Always On Top (WindowCornerUtils::CornersRadius).
static int CornerRadiusForWindow(HWND hwnd)
{
// Remote sessions draw square windows even on Win11, yet still report DWMWCP_DEFAULT. Match the
// window: a remote session gets square (radius 0) so the overlay border doesn't round off the corner.
if (GetSystemMetrics(SM_REMOTESESSION))
{
return 0;
}
int pref = 0; // DWMWCP_DEFAULT
if (DwmGetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)) != S_OK)
{

View File

@@ -6,8 +6,6 @@
#include "trace.h"
#include <cmath>
#include <algorithm>
#include <memory>
#include <vector>
#ifdef COMPOSITION
namespace winrt
@@ -50,18 +48,6 @@ private:
void ClearDrawingPoint();
void ClearDrawing();
void BringToFront();
// Ripple mode: spawn the press/hold ring + glow at the click point and
// continue the animation into a fade-out on release. The held ring may
// optionally follow the cursor while held (gated by m_rippleShowDragTrail).
void SpawnRippleHoldDot(MouseButton button);
void FadeRippleHoldDot(MouseButton button);
// Ripple mode: emit a single self-contained ripple (grow + fade) for a quick
// click, independent of any held indicator.
void EmitSingleRipple(MouseButton button);
// Spotlight mode: pressed-state animation that shrinks the mask while
// a mouse button is held and restores it on release.
void SpotlightAnimatePress();
void SpotlightAnimateRelease();
HHOOK m_mouseHook = NULL;
static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept;
// Helpers for spotlight overlay
@@ -85,16 +71,6 @@ private:
winrt::CompositionSpriteShape m_leftPointer{ nullptr };
winrt::CompositionSpriteShape m_rightPointer{ nullptr };
winrt::CompositionSpriteShape m_alwaysPointer{ nullptr };
// Ellipse geometries kept alongside the pointer shapes so press-down /
// release animations can target the radius directly.
winrt::CompositionEllipseGeometry m_leftGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGeometry{ nullptr };
// Ripple-mode held glow (the soft halo behind the ring) — paired with
// m_left/rightPointer (which holds the ring shape) while a button is held.
winrt::CompositionSpriteShape m_leftRippleGlow{ nullptr };
winrt::CompositionSpriteShape m_rightRippleGlow{ nullptr };
winrt::CompositionEllipseGeometry m_leftGlowGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGlowGeometry{ nullptr };
// Spotlight overlay (mask with soft feathered edge)
winrt::SpriteVisual m_overlay{ nullptr };
winrt::CompositionMaskBrush m_spotlightMask{ nullptr };
@@ -108,22 +84,9 @@ private:
bool m_rightPointerEnabled = true;
bool m_alwaysPointerEnabled = true;
bool m_spotlightMode = false;
bool m_spotlightPressed = false;
bool m_rippleMode = true;
bool m_rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
bool m_rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
float m_rippleSize = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
float m_rippleIntensity = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
int m_rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
bool m_leftButtonPressed = false;
bool m_rightButtonPressed = false;
// Pending hold-detection timers. A ripple "held indicator" is only spawned
// once the button has been held past a short threshold; a quick click that
// releases before then emits a single self-contained ripple instead. This
// prevents a single click from rendering two ripples (press + release).
UINT_PTR m_leftHoldTimer = 0;
UINT_PTR m_rightHoldTimer = 0;
UINT_PTR m_timer_id = 0;
bool m_visible = false;
@@ -139,11 +102,6 @@ private:
winrt::Windows::UI::Color m_alwaysColor = MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR;
};
static const uint32_t BRING_TO_FRONT_TIMER_ID = 123;
static const uint32_t HOLD_RIPPLE_TIMER_LEFT = 124;
static const uint32_t HOLD_RIPPLE_TIMER_RIGHT = 125;
// How long a ripple button must be held before the persistent "held indicator"
// is shown. Releasing before this is treated as a quick click (single ripple).
static const uint32_t HOLD_RIPPLE_THRESHOLD_MS = 180;
Highlighter* Highlighter::instance = nullptr;
bool Highlighter::CreateHighlighter()
@@ -236,34 +194,11 @@ void Highlighter::AddDrawingPoint(MouseButton button)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_leftClickColor));
m_leftPointer = circleShape;
m_leftGeometry = circleGeometry;
// Niels-style press-down shrink: holding the button squeezes the
// circle to 70% over 180ms after a 150ms delay so quick clicks skip
// it. StartDrawingPointFading stops this animation on release.
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else if (button == MouseButton::Right)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_rightClickColor));
m_rightPointer = circleShape;
m_rightGeometry = circleGeometry;
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else
{
@@ -303,36 +238,17 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
if (button == MouseButton::Left)
{
m_leftPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_leftRippleGlow)
{
m_leftRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else if (button == MouseButton::Right)
{
m_rightPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_rightRippleGlow)
{
m_rightRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
// always / spotlight idle
if (m_spotlightMode)
{
if (m_spotlightPressed)
{
// Only update position while pressed — radius is being animated
if (m_spotlightMaskGradient)
{
m_spotlightMaskGradient.EllipseCenter({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
}
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
}
else if (m_alwaysPointer)
{
@@ -343,24 +259,14 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
void Highlighter::StartDrawingPointFading(MouseButton button)
{
winrt::Windows::UI::Composition::CompositionSpriteShape circleShape{ nullptr };
winrt::Windows::UI::Composition::CompositionEllipseGeometry geom{ nullptr };
if (button == MouseButton::Left)
{
circleShape = m_leftPointer;
geom = m_leftGeometry;
}
else
{
// right
circleShape = m_rightPointer;
geom = m_rightGeometry;
}
// Stop any in-flight press-down shrink so the geometry doesn't keep
// animating while the fill is being faded out.
if (geom && m_compositor)
{
geom.StopAnimation(L"Radius");
}
auto brushColor = circleShape.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color();
@@ -423,30 +329,6 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
switch (wParam)
{
case WM_LBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_leftPointerEnabled)
{
// Defer the held indicator: only spawn it if the button is
// still down after the hold threshold. A quick click handled
// on button-up emits a single ripple instead.
instance->m_leftButtonPressed = true;
if (instance->m_leftHoldTimer == 0)
{
instance->m_leftHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_LEFT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
}
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_leftPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
@@ -472,28 +354,6 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_rightPointerEnabled)
{
// Defer the held indicator (see WM_LBUTTONDOWN).
instance->m_rightButtonPressed = true;
if (instance->m_rightHoldTimer == 0)
{
instance->m_rightHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_RIGHT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
}
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_rightPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
@@ -516,24 +376,6 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_MOUSEMOVE:
if (instance->m_rippleMode)
{
// Held ripple ring follows the cursor while a button is down,
// gated by the "follow cursor while held" setting. When the
// setting is off, the ring stays anchored at the click point.
if (instance->m_rippleShowDragTrail)
{
if (instance->m_leftButtonPressed && instance->m_leftPointer)
{
instance->UpdateDrawingPointPosition(MouseButton::Left);
}
if (instance->m_rightButtonPressed && instance->m_rightPointer)
{
instance->UpdateDrawingPointPosition(MouseButton::Right);
}
}
break;
}
if (instance->m_leftButtonPressed)
{
instance->UpdateDrawingPointPosition(MouseButton::Left);
@@ -548,33 +390,11 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_LBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_leftButtonPressed)
{
if (instance->m_rippleMode)
{
if (instance->m_leftHoldTimer != 0)
{
// Released before the hold threshold => quick click.
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
instance->m_leftHoldTimer = 0;
instance->EmitSingleRipple(MouseButton::Left);
}
else
{
// Held indicator was already shown; expand + fade it.
instance->FadeRippleHoldDot(MouseButton::Left);
}
}
else
{
instance->StartDrawingPointFading(MouseButton::Left);
}
instance->StartDrawingPointFading(MouseButton::Left);
instance->m_leftButtonPressed = false;
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
{
// Add AlwaysPointer only when it's enabled and RightPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -582,32 +402,11 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_rightButtonPressed)
{
if (instance->m_rippleMode)
{
if (instance->m_rightHoldTimer != 0)
{
// Released before the hold threshold => quick click.
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
instance->m_rightHoldTimer = 0;
instance->EmitSingleRipple(MouseButton::Right);
}
else
{
instance->FadeRippleHoldDot(MouseButton::Right);
}
}
else
{
instance->StartDrawingPointFading(MouseButton::Right);
}
instance->StartDrawingPointFading(MouseButton::Right);
instance->m_rightButtonPressed = false;
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
{
// Add AlwaysPointer only when it's enabled and LeftPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -649,16 +448,9 @@ void Highlighter::StopDrawing()
m_visible = false;
m_leftButtonPressed = false;
m_rightButtonPressed = false;
m_spotlightPressed = false;
m_leftPointer = nullptr;
m_rightPointer = nullptr;
m_alwaysPointer = nullptr;
m_leftGeometry = nullptr;
m_rightGeometry = nullptr;
m_leftRippleGlow = nullptr;
m_rightRippleGlow = nullptr;
m_leftGlowGeometry = nullptr;
m_rightGlowGeometry = nullptr;
if (m_overlay)
{
m_overlay.IsVisible(false);
@@ -686,16 +478,6 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings)
m_rightPointerEnabled = settings.rightButtonColor.A != 0;
m_alwaysPointerEnabled = settings.alwaysColor.A != 0;
m_spotlightMode = settings.spotlightMode && settings.alwaysColor.A != 0;
m_rippleMode = settings.rippleMode && !m_spotlightMode;
m_rippleSize = (settings.rippleSize > 0) ? static_cast<float>(settings.rippleSize) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
m_rippleIntensity = (settings.rippleIntensity > 0.0) ? static_cast<float>(settings.rippleIntensity) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
m_rippleDurationMs = (settings.rippleDurationMs > 0) ? settings.rippleDurationMs : MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
m_rippleShowDragTrail = settings.rippleShowDragTrail;
m_rippleShowReleasePulse = settings.rippleShowReleasePulse;
// Reset transient pressed-state flag so a settings change while a button
// happens to be down doesn't leave the spotlight stuck at a shrunken size.
m_spotlightPressed = false;
if (m_spotlightMode)
{
@@ -766,7 +548,6 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
// If we would use a timer with a 50 ms period, there would be a flickering on the UI, as in most of the cases
// the pinned window hides our window in a few milliseconds.
case BRING_TO_FRONT_TIMER_ID:
{
static int fireCount = 0;
if (fireCount++ >= 4)
{
@@ -777,24 +558,6 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
instance->BringToFront();
break;
}
case HOLD_RIPPLE_TIMER_LEFT:
// Button held past the threshold: show the persistent held indicator.
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
instance->m_leftHoldTimer = 0;
if (instance->m_leftButtonPressed)
{
instance->SpawnRippleHoldDot(MouseButton::Left);
}
break;
case HOLD_RIPPLE_TIMER_RIGHT:
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
instance->m_rightHoldTimer = 0;
if (instance->m_rightButtonPressed)
{
instance->SpawnRippleHoldDot(MouseButton::Right);
}
break;
}
break;
}
default:
@@ -880,548 +643,6 @@ void Highlighter::UpdateSpotlightMask(float cx, float cy, float radius, bool sho
}
}
// Spotlight press-down: shrink the mask radius briefly while a button is held.
void Highlighter::SpotlightAnimatePress()
{
if (!m_spotlightMode || !m_spotlightMaskGradient)
{
return;
}
m_spotlightPressed = true;
const float pressedRadius = m_radius * 0.85f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(120));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Spotlight release: animate the mask back to the configured radius.
void Highlighter::SpotlightAnimateRelease()
{
m_spotlightPressed = false;
if (!m_spotlightMode || !m_spotlightMaskGradient)
{
return;
}
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto current = m_spotlightMaskGradient.EllipseRadius();
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, current);
anim.InsertKeyFrame(1.0f, { m_radius, m_radius }, ease);
anim.Duration(std::chrono::milliseconds(200));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Spawn the press/hold ring + glow at the click point. The shapes persist
// until FadeRippleHoldDot is called (button-up). While held they can be
// re-positioned to follow the cursor (UpdateDrawingPointPosition).
void Highlighter::SpawnRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
if (color.A == 0)
{
return;
}
POINT pt{};
if (!GetCursorPos(&pt))
{
return;
}
ScreenToClient(m_hwnd, &pt);
const float fx = static_cast<float>(pt.x);
const float fy = static_cast<float>(pt.y);
// Resolve sizing/intensity from the ripple-specific settings so they're
// independent of the legacy "always-on dot" controls.
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
const float ringHeld = baseSize * 0.55f;
const float glowHeld = baseSize * 0.65f;
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
// Held indicator: appears once the button has been held past the hold
// threshold and sits at the held radius until release. It must NOT expand
// outward on appearance — it only FADES IN at the held size. The single
// outward "ripple" expansion happens exclusively on release
// (FadeRippleHoldDot). If this grew outward, a slow single click (release
// shortly after the threshold) would show grow-to-held + release as two
// expansions — the double-ripple bug.
auto dur = std::chrono::milliseconds(120);
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
// Glow color is the click color, lower alpha (×0.30), scaled by intensity.
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
auto glowTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Ring color uses full base alpha (alphaMul like the press recipe).
const float alphaMul = 0.18f + intensity * 0.78f;
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
auto ringTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Clean up any stray "still held" shapes for this button — guards against
// stray button-down without matching button-up (e.g. focus loss).
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
if (m_shape && m_shape.Shapes())
{
auto shapes = m_shape.Shapes();
uint32_t idx = 0;
if (heldRing && shapes.IndexOf(heldRing, idx))
{
shapes.RemoveAt(idx);
}
if (heldGlow && shapes.IndexOf(heldGlow, idx))
{
shapes.RemoveAt(idx);
}
}
// Glow (filled) — added first so the ring renders on top. Sits at the held
// radius and fades its alpha in (no outward size growth).
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowHeld, glowHeld });
auto glowBrush = m_compositor.CreateColorBrush(glowTransparent);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ fx, fy });
glowShape.FillBrush(glowBrush);
m_shape.Shapes().Append(glowShape);
auto glowFadeIn = m_compositor.CreateColorKeyFrameAnimation();
glowFadeIn.InsertKeyFrame(0.0f, glowTransparent);
glowFadeIn.InsertKeyFrame(1.0f, glowColor, ease);
glowFadeIn.Duration(dur);
glowBrush.StartAnimation(L"Color", glowFadeIn);
// Ring (stroked) — same: fixed at held radius, alpha fade-in only.
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringHeld, ringHeld });
auto ringBrush = m_compositor.CreateColorBrush(ringTransparent);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ fx, fy });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness(lineWidth);
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(ringShape);
auto ringFadeIn = m_compositor.CreateColorKeyFrameAnimation();
ringFadeIn.InsertKeyFrame(0.0f, ringTransparent);
ringFadeIn.InsertKeyFrame(1.0f, ringColor, ease);
ringFadeIn.Duration(dur);
ringBrush.StartAnimation(L"Color", ringFadeIn);
heldRing = ringShape;
heldGlow = glowShape;
heldGeom = ringGeom;
heldGlowGeom = glowGeom;
}
// Continue the held-ring/glow animation outward and fade both to transparent.
// For right-click, optionally spawn the expanding crosshair lines.
void Highlighter::FadeRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
if (!heldRing && !heldGlow)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
int durationMs = m_rippleDurationMs;
if (durationMs < 60) durationMs = 60;
if (durationMs > 2000) durationMs = 2000;
auto dur = std::chrono::milliseconds(durationMs);
const float ringHeld = baseSize * 0.55f;
const float ringEnd = baseSize * 1.05f;
const float glowHeld = baseSize * 0.65f;
const float glowEnd = baseSize * 1.40f;
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Track everything spawned by this fade (and the held shapes themselves)
// so the completion callback can remove them in one pass.
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
if (heldGlow && heldGlowGeom)
{
// The held indicator has settled at the held radius; expand it outward
// from there and fade it to transparent.
heldGlowGeom.StopAnimation(L"Radius");
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowHeld, glowHeld });
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowAnim.Duration(dur);
heldGlowGeom.StartAnimation(L"Radius", glowAnim);
auto brush = heldGlow.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
auto startColor = brush.Color();
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, startColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
brush.StartAnimation(L"Color", colorAnim);
spawned->push_back(heldGlow);
}
if (heldRing && heldGeom)
{
heldGeom.StopAnimation(L"Radius");
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringHeld, ringHeld });
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringAnim.Duration(dur);
heldGeom.StartAnimation(L"Radius", ringAnim);
auto brush = heldRing.StrokeBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
auto startColor = brush.Color();
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, startColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
brush.StartAnimation(L"Color", colorAnim);
spawned->push_back(heldRing);
}
// Right-click only: spawn expanding crosshair lines centered on the ring.
// Gated by the "show crosshairs on right-click release" toggle.
if (button == MouseButton::Right && m_rippleShowReleasePulse && heldRing)
{
const float xhairAlphaMul = 0.18f + intensity * 0.78f;
auto xhairColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * xhairAlphaMul), color.R, color.G, color.B);
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
auto center = heldRing.Offset();
const float startSpan = ringHeld * 0.85f;
const float endSpan = ringEnd * 0.85f;
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
float bx1, float by1, float bx2, float by2) {
auto lineGeom = m_compositor.CreateLineGeometry();
lineGeom.Start({ ax1, ay1 });
lineGeom.End({ ax2, ay2 });
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
lineShape.StrokeBrush(lineBrush);
lineShape.StrokeThickness(xhairThickness);
lineShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(lineShape);
spawned->push_back(lineShape);
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
startAnim.Duration(dur);
lineGeom.StartAnimation(L"Start", startAnim);
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
endAnim.Duration(dur);
lineGeom.StartAnimation(L"End", endAnim);
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, xhairColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
lineBrush.StartAnimation(L"Color", colorAnim);
};
// Horizontal line (left half, right half).
makeLine(center.x - startSpan, center.y, center.x - startSpan * 0.30f, center.y,
center.x - endSpan, center.y, center.x - endSpan * 0.30f, center.y);
makeLine(center.x + startSpan * 0.30f, center.y, center.x + startSpan, center.y,
center.x + endSpan * 0.30f, center.y, center.x + endSpan, center.y);
// Vertical line (top half, bottom half).
makeLine(center.x, center.y - startSpan, center.x, center.y - startSpan * 0.30f,
center.x, center.y - endSpan, center.x, center.y - endSpan * 0.30f);
makeLine(center.x, center.y + startSpan * 0.30f, center.x, center.y + startSpan,
center.x, center.y + endSpan * 0.30f, center.x, center.y + endSpan);
}
// Detach our member handles BEFORE the batch completes so subsequent
// press events on this button create fresh shapes rather than racing.
heldRing = nullptr;
heldGlow = nullptr;
heldGeom = nullptr;
heldGlowGeom = nullptr;
batch.End();
if (spawned->empty())
{
return;
}
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
dispatcher.TryEnqueue([spawned]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
for (auto const& s : *spawned)
{
uint32_t index = 0;
if (shapes.IndexOf(s, index))
{
shapes.RemoveAt(index);
}
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
// Self-contained single ripple for a quick click (press + release before the
// hold threshold). Spawns a fresh ring + glow that grow from the click point
// outward and fade to transparent in one continuous animation — no held
// indicator, so a single click produces exactly one ripple. For right-click,
// optionally spawns the expanding crosshair lines too.
void Highlighter::EmitSingleRipple(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
if (color.A == 0)
{
return;
}
POINT pt{};
if (!GetCursorPos(&pt))
{
return;
}
ScreenToClient(m_hwnd, &pt);
const float fx = static_cast<float>(pt.x);
const float fy = static_cast<float>(pt.y);
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
int durationMs = m_rippleDurationMs;
if (durationMs < 60) durationMs = 60;
if (durationMs > 2000) durationMs = 2000;
auto dur = std::chrono::milliseconds(durationMs);
const float ringStart = baseSize * 0.20f;
const float ringEnd = baseSize * 1.05f;
const float glowStart = baseSize * 0.30f;
const float glowEnd = baseSize * 1.40f;
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
const float alphaMul = 0.18f + intensity * 0.78f;
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
// Glow (filled) — added first so the ring renders on top.
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowStart, glowStart });
auto glowBrush = m_compositor.CreateColorBrush(glowColor);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ fx, fy });
glowShape.FillBrush(glowBrush);
m_shape.Shapes().Append(glowShape);
spawned->push_back(glowShape);
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowStart, glowStart });
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowAnim.Duration(dur);
glowGeom.StartAnimation(L"Radius", glowAnim);
auto glowColorAnim = m_compositor.CreateColorKeyFrameAnimation();
glowColorAnim.InsertKeyFrame(0.0f, glowColor);
glowColorAnim.InsertKeyFrame(1.0f, transparent, ease);
glowColorAnim.Duration(dur);
glowBrush.StartAnimation(L"Color", glowColorAnim);
// Ring (stroked).
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringStart, ringStart });
auto ringBrush = m_compositor.CreateColorBrush(ringColor);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ fx, fy });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness(lineWidth);
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(ringShape);
spawned->push_back(ringShape);
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringStart, ringStart });
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringAnim.Duration(dur);
ringGeom.StartAnimation(L"Radius", ringAnim);
auto ringColorAnim = m_compositor.CreateColorKeyFrameAnimation();
ringColorAnim.InsertKeyFrame(0.0f, ringColor);
ringColorAnim.InsertKeyFrame(1.0f, transparent, ease);
ringColorAnim.Duration(dur);
ringBrush.StartAnimation(L"Color", ringColorAnim);
// Right-click only: spawn expanding crosshair lines centered on the click
// point. Gated by the "show crosshairs on right-click release" toggle.
if (button == MouseButton::Right && m_rippleShowReleasePulse)
{
auto xhairColor = ringColor;
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
const float startSpan = (baseSize * 0.55f) * 0.85f;
const float endSpan = ringEnd * 0.85f;
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
float bx1, float by1, float bx2, float by2) {
auto lineGeom = m_compositor.CreateLineGeometry();
lineGeom.Start({ ax1, ay1 });
lineGeom.End({ ax2, ay2 });
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
lineShape.StrokeBrush(lineBrush);
lineShape.StrokeThickness(xhairThickness);
lineShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(lineShape);
spawned->push_back(lineShape);
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
startAnim.Duration(dur);
lineGeom.StartAnimation(L"Start", startAnim);
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
endAnim.Duration(dur);
lineGeom.StartAnimation(L"End", endAnim);
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, xhairColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
lineBrush.StartAnimation(L"Color", colorAnim);
};
// Horizontal line (left half, right half).
makeLine(fx - startSpan, fy, fx - startSpan * 0.30f, fy,
fx - endSpan, fy, fx - endSpan * 0.30f, fy);
makeLine(fx + startSpan * 0.30f, fy, fx + startSpan, fy,
fx + endSpan * 0.30f, fy, fx + endSpan, fy);
// Vertical line (top half, bottom half).
makeLine(fx, fy - startSpan, fx, fy - startSpan * 0.30f,
fx, fy - endSpan, fx, fy - endSpan * 0.30f);
makeLine(fx, fy + startSpan * 0.30f, fx, fy + startSpan,
fx, fy + endSpan * 0.30f, fx, fy + endSpan);
}
batch.End();
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
dispatcher.TryEnqueue([spawned]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
for (auto const& s : *spawned)
{
uint32_t index = 0;
if (shapes.IndexOf(s, index))
{
shapes.RemoveAt(index);
}
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
#pragma region MouseHighlighter_API
void MouseHighlighterApplySettings(MouseHighlighterSettings settings)

View File

@@ -4,16 +4,10 @@
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_LEFT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 255, 255, 0);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_RIGHT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 0, 0, 255);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(0, 255, 0, 0);
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 30;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 400;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 400;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 20;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 500;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 250;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE = false;
// Ripple-specific defaults (independent of the always-on circle settings above).
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE = 60;
constexpr double MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY = 0.7;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS = 480;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL = true;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE = true;
struct MouseHighlighterSettings
{
@@ -25,12 +19,6 @@ struct MouseHighlighterSettings
int fadeDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS;
bool autoActivate = MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE;
bool spotlightMode = false;
bool rippleMode = true;
int rippleSize = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE;
double rippleIntensity = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY;
int rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
bool rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
bool rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
};
int MouseHighlighterMain(HINSTANCE hinst, MouseHighlighterSettings settings);

View File

@@ -21,12 +21,6 @@ namespace
const wchar_t JSON_KEY_HIGHLIGHT_FADE_DURATION_MS[] = L"highlight_fade_duration_ms";
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
const wchar_t JSON_KEY_SPOTLIGHT_MODE[] = L"spotlight_mode";
const wchar_t JSON_KEY_RIPPLE_MODE[] = L"ripple_mode";
const wchar_t JSON_KEY_RIPPLE_SIZE[] = L"ripple_size";
const wchar_t JSON_KEY_RIPPLE_INTENSITY[] = L"ripple_intensity";
const wchar_t JSON_KEY_RIPPLE_DURATION_MS[] = L"ripple_duration_ms";
const wchar_t JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL[] = L"ripple_show_drag_trail";
const wchar_t JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE[] = L"ripple_show_release_pulse";
}
extern "C" IMAGE_DOS_HEADER __ImageBase;
@@ -398,90 +392,6 @@ public:
{
Logger::warn("Failed to initialize spotlight mode settings. Will use default value");
}
try
{
// Parse ripple mode
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_MODE);
highlightSettings.rippleMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple mode settings. Will use default value");
}
try
{
// Parse ripple size
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SIZE);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.rippleSize = value;
}
else
{
throw std::runtime_error("Invalid ripple size value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple size from settings. Will use default value");
}
try
{
// Parse ripple intensity
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_INTENSITY);
double value = jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE);
if (value > 0.0)
{
highlightSettings.rippleIntensity = value;
}
else
{
throw std::runtime_error("Invalid ripple intensity value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple intensity from settings. Will use default value");
}
try
{
// Parse ripple duration
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_DURATION_MS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.rippleDurationMs = value;
}
else
{
throw std::runtime_error("Invalid ripple duration value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple duration from settings. Will use default value");
}
try
{
// Parse ripple show drag trail
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL);
highlightSettings.rippleShowDragTrail = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple show drag trail from settings. Will use default value");
}
try
{
// Parse ripple show release pulse
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE);
highlightSettings.rippleShowReleasePulse = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple show release pulse from settings. Will use default value");
}
}
else
{

View File

@@ -39,7 +39,6 @@
</ItemGroup>
<ItemGroup>
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natstepfilter" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
</packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
</packages>

View File

@@ -0,0 +1,17 @@
<Project>
<!--
PROTOTYPE-ONLY build props for the PowerScripts module.
Intentionally does NOT import the repo-root Directory.Build.props so the
prototype stays isolated from StyleCop / TreatWarningsAsErrors / Central
Package Management while we iterate. Before promoting PowerScripts out of
prototype status, delete this file so the projects inherit the standard
PowerToys build configuration and analyzers.
-->
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,3 @@
<Project>
<!-- Empty: stops MSBuild from walking up to the repo-root targets for the prototype. -->
</Project>

View File

@@ -0,0 +1,11 @@
<Project>
<!--
PROTOTYPE-ONLY: stops NuGet from discovering the repo-root Directory.Packages.props and
disables Central Package Management so the prototype projects can pin their own PackageReference
versions in isolation. Remove together with the local Directory.Build.props when promoting the
module to the standard PowerToys build.
-->
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,87 @@
// 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.VisualStudio.TestTools.UnitTesting;
using PowerScripts.Core.Manifest;
namespace PowerScripts.Core.Tests;
[TestClass]
public class ManifestTests
{
[TestMethod]
public void Serializer_RoundTrips_WithCamelCaseEnums()
{
var manifest = new PowerScriptManifest
{
Id = "demo",
Name = "Demo",
Kind = ScriptKind.File,
Runtime = ScriptRuntime.PowerShell,
Entry = "run.ps1",
Input = new ScriptInput { Extensions = { ".png" }, MinFiles = 1, MaxFiles = 0 },
Output = new ScriptOutput { Type = ScriptOutputType.SideEffect },
Surfaces = { "contextMenu" },
};
var json = ManifestSerializer.Serialize(manifest);
StringAssert.Contains(json, "\"kind\": \"file\"");
StringAssert.Contains(json, "\"runtime\": \"powerShell\"");
var back = ManifestSerializer.Deserialize(json);
Assert.IsNotNull(back);
Assert.AreEqual(ScriptKind.File, back!.Kind);
Assert.AreEqual(ScriptOutputType.SideEffect, back.Output!.Type);
Assert.AreEqual(".png", back.Input!.Extensions[0]);
}
[TestMethod]
public void Validator_Allows_IdFolderMismatch()
{
// A script's id is portable and intentionally decoupled from its folder name, so a mismatch
// is no longer an error (a downloaded/shared script keeps its id in any folder).
var manifest = new PowerScriptManifest { Id = "abc", Name = "x", Entry = "run.ps1" };
var errors = ManifestValidator.Validate(manifest, folderName: "different");
Assert.AreEqual(0, errors.Count);
}
[TestMethod]
public void Validator_Flags_MissingId()
{
var manifest = new PowerScriptManifest { Id = string.Empty, Name = "x", Entry = "run.ps1" };
var errors = ManifestValidator.Validate(manifest, folderName: "abc");
Assert.IsTrue(errors.Any(e => e.Contains("'id' is required")));
}
[TestMethod]
public void Validator_Flags_FileKind_WithoutExtensions()
{
var manifest = new PowerScriptManifest
{
Id = "abc",
Name = "x",
Entry = "run.ps1",
Kind = ScriptKind.File,
};
var errors = ManifestValidator.Validate(manifest, "abc");
Assert.IsTrue(errors.Any(e => e.Contains("input.extensions")));
}
[TestMethod]
public void Validator_Flags_MaxFiles_LessThanMin()
{
var manifest = new PowerScriptManifest
{
Id = "abc",
Name = "x",
Entry = "run.ps1",
Kind = ScriptKind.File,
Input = new ScriptInput { Extensions = { ".png" }, MinFiles = 3, MaxFiles = 2 },
};
var errors = ManifestValidator.Validate(manifest, "abc");
Assert.IsTrue(errors.Any(e => e.Contains("maxFiles")));
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>PowerScripts.Core.Tests</RootNamespace>
<AssemblyName>PowerScripts.Core.Tests</AssemblyName>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerScripts.Core\PowerScripts.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,166 @@
// 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.VisualStudio.TestTools.UnitTesting;
using PowerScripts.Core.Manifest;
using PowerScripts.Core.Registry;
namespace PowerScripts.Core.Tests;
[TestClass]
public class ScriptRegistryTests
{
private string _root = string.Empty;
[TestInitialize]
public void Setup()
{
_root = Path.Combine(Path.GetTempPath(), "powerscripts-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
[TestCleanup]
public void Cleanup()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private void WriteScript(string id, string manifestJson, string entryFile = "run.ps1")
{
var folder = Path.Combine(_root, id);
Directory.CreateDirectory(folder);
File.WriteAllText(Path.Combine(folder, "manifest.json"), manifestJson);
File.WriteAllText(Path.Combine(folder, entryFile), "# noop");
}
[TestMethod]
public void Load_Skips_Invalid_And_Records_Error()
{
WriteScript("good", """
{ "id": "good", "name": "Good", "kind": "system", "entry": "run.ps1" }
""");
// Missing 'id' -> should be rejected.
WriteScript("bad", """
{ "name": "Bad", "kind": "system", "entry": "run.ps1" }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
Assert.AreEqual(1, registry.Scripts.Count);
Assert.AreEqual("good", registry.Scripts[0].Id);
Assert.AreEqual(1, registry.Errors.Count);
}
[TestMethod]
public void Load_Allows_IdDecoupledFromFolder()
{
// The folder name differs from the id; the script is still loaded and keyed by its id.
WriteScript("some-folder", """
{ "id": "portable.id", "name": "Portable", "kind": "system", "entry": "run.ps1" }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
Assert.AreEqual(1, registry.Scripts.Count);
Assert.AreEqual("portable.id", registry.Scripts[0].Id);
Assert.AreEqual(0, registry.Errors.Count);
Assert.IsNotNull(registry.Get("portable.id"));
}
[TestMethod]
public void Load_Rejects_DuplicateIds()
{
WriteScript("folder-a", """
{ "id": "dup", "name": "First", "kind": "system", "entry": "run.ps1" }
""");
WriteScript("folder-b", """
{ "id": "dup", "name": "Second", "kind": "system", "entry": "run.ps1" }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
// Only the first wins; the collision is reported.
Assert.AreEqual(1, registry.Scripts.Count);
Assert.AreEqual(1, registry.Errors.Count);
Assert.IsTrue(registry.Errors[0].Message.Contains("duplicate id"));
}
[TestMethod]
public void FileScriptsFor_Matches_Extension_And_Wildcard()
{
WriteScript("png-only", """
{ "id": "png-only", "name": "PNG", "kind": "file", "entry": "run.ps1",
"input": { "extensions": [".png"], "minFiles": 1, "maxFiles": 0 } }
""");
WriteScript("any-file", """
{ "id": "any-file", "name": "Any", "kind": "file", "entry": "run.ps1",
"input": { "extensions": ["*"], "minFiles": 1, "maxFiles": 0 } }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
var forPng = registry.FileScriptsFor(".PNG").Select(s => s.Id).OrderBy(x => x).ToList();
CollectionAssert.AreEqual(new[] { "any-file", "png-only" }, forPng);
var forTxt = registry.FileScriptsFor(".txt").Select(s => s.Id).ToList();
CollectionAssert.AreEqual(new[] { "any-file" }, forTxt);
}
[TestMethod]
public void FileScriptsForSelection_Respects_MinMax_And_MixedExtensions()
{
WriteScript("single-png", """
{ "id": "single-png", "name": "Single PNG", "kind": "file", "entry": "run.ps1",
"input": { "extensions": [".png"], "minFiles": 1, "maxFiles": 1 } }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
// Two files exceeds maxFiles=1.
Assert.AreEqual(0, registry.FileScriptsForSelection(new[] { "a.png", "b.png" }).Count());
// One file is fine.
Assert.AreEqual(1, registry.FileScriptsForSelection(new[] { "a.png" }).Count());
// Mixed extensions: not all match .png.
Assert.AreEqual(0, registry.FileScriptsForSelection(new[] { "a.txt" }).Count());
}
[TestMethod]
public void SystemScripts_Filters_ByKind()
{
WriteScript("sys", """
{ "id": "sys", "name": "Sys", "kind": "system", "entry": "run.ps1" }
""");
WriteScript("file", """
{ "id": "file", "name": "File", "kind": "file", "entry": "run.ps1",
"input": { "extensions": ["*"] } }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
var system = registry.SystemScripts.Select(s => s.Id).ToList();
CollectionAssert.AreEqual(new[] { "sys" }, system);
}
[TestMethod]
public void Load_EmptyRoot_YieldsNoScripts()
{
var registry = new ScriptRegistry(_root);
registry.Load();
Assert.AreEqual(0, registry.Scripts.Count);
Assert.AreEqual(0, registry.Errors.Count);
}
}

View File

@@ -0,0 +1,105 @@
// 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.VisualStudio.TestTools.UnitTesting;
using PowerScripts.Core.Manifest;
using PowerScripts.Core.Security;
namespace PowerScripts.Core.Tests;
[TestClass]
public class SecurityTests
{
private string _folder = string.Empty;
[TestInitialize]
public void Setup()
{
_folder = Path.Combine(Path.GetTempPath(), "powerscripts-sec-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_folder);
}
[TestCleanup]
public void Cleanup()
{
if (Directory.Exists(_folder))
{
Directory.Delete(_folder, recursive: true);
}
}
private PowerScriptManifest WriteScript(string id, string body, params string[] capabilities)
{
var entry = "run.ps1";
File.WriteAllText(Path.Combine(_folder, entry), body);
return new PowerScriptManifest
{
Id = id,
Name = id,
Kind = ScriptKind.System,
Entry = entry,
FolderPath = _folder,
Capabilities = capabilities.ToList(),
};
}
[TestMethod]
public void Integrity_IsStable_ForSameContent()
{
var a = WriteScript("s", "Write-Host hi");
var first = ScriptIntegrity.ComputeHash(a);
var second = ScriptIntegrity.ComputeHash(a);
Assert.AreEqual(first, second);
Assert.AreNotEqual(string.Empty, first);
}
[TestMethod]
public void Integrity_Changes_WhenBodyChanges()
{
var a = WriteScript("s", "Write-Host hi");
var before = ScriptIntegrity.ComputeHash(a);
File.WriteAllText(Path.Combine(_folder, "run.ps1"), "Remove-Item C:\\ -Recurse");
var after = ScriptIntegrity.ComputeHash(a);
Assert.AreNotEqual(before, after);
}
[TestMethod]
public void Integrity_Changes_WhenCapabilitiesChange()
{
var a = WriteScript("s", "Write-Host hi", "fileRead");
var before = ScriptIntegrity.ComputeHash(a);
var b = WriteScript("s", "Write-Host hi", "fileRead", "process");
var after = ScriptIntegrity.ComputeHash(b);
Assert.AreNotEqual(before, after);
}
[TestMethod]
public void TrustStore_RoundTrips_And_Enforces_Hash()
{
var path = Path.Combine(_folder, "trust.json");
var manifest = WriteScript("s", "Write-Host hi");
var hash = ScriptIntegrity.ComputeHash(manifest);
var store = new TrustStore(path);
Assert.IsFalse(store.IsTrusted("s", hash));
store.Trust(new TrustRecord { Id = "s", Hash = hash, ApprovedUtc = DateTimeOffset.UtcNow });
Assert.IsTrue(store.IsTrusted("s", hash));
// A different content hash for the same id is NOT trusted (edit invalidates approval).
Assert.IsFalse(store.IsTrusted("s", "deadbeef"));
// Persisted across instances.
var reopened = new TrustStore(path);
Assert.IsTrue(reopened.IsTrusted("s", hash));
// Revoke clears it.
Assert.IsTrue(reopened.Revoke("s"));
Assert.IsFalse(new TrustStore(path).IsTrusted("s", hash));
}
}

View File

@@ -0,0 +1,137 @@
// 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 PowerScripts.Core.Manifest;
namespace PowerScripts.Core.Execution;
/// <summary>
/// The outcome of running a PowerScript.
/// </summary>
public sealed class ScriptExecutionResult
{
public int ExitCode { get; init; }
public bool Succeeded => ExitCode == 0;
public string StdOut { get; init; } = string.Empty;
public string StdErr { get; init; } = string.Empty;
}
/// <summary>
/// Runs a PowerScript. This is the single execution path shared by every surface (context menu,
/// Keyboard Manager, Command Palette, agents) so behavior and security posture stay consistent.
///
/// Prototype security posture: always runs non-elevated under the invoking user's token, with the
/// PowerShell profile disabled and a per-run execution policy of Bypass scoped to the launched
/// process only. Signing / capability enforcement is intentionally out of scope for the prototype.
/// </summary>
public sealed class ScriptExecutor
{
/// <summary>Environment variable the script can read to get the newline-separated input files.</summary>
public const string FilesEnvironmentVariable = "POWERSCRIPTS_FILES";
public ScriptExecutionResult Execute(
PowerScriptManifest manifest,
IReadOnlyList<string>? files = null,
IReadOnlyDictionary<string, string?>? parameters = null)
{
if (manifest.Runtime != ScriptRuntime.PowerShell)
{
throw new NotSupportedException($"Runtime '{manifest.Runtime}' is not supported in the prototype.");
}
if (!File.Exists(manifest.EntryFullPath))
{
throw new FileNotFoundException("Script entry file not found.", manifest.EntryFullPath);
}
files ??= Array.Empty<string>();
var psi = new ProcessStartInfo
{
FileName = ResolvePowerShellExecutable(),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = manifest.FolderPath,
};
psi.ArgumentList.Add("-NoProfile");
psi.ArgumentList.Add("-NonInteractive");
psi.ArgumentList.Add("-ExecutionPolicy");
psi.ArgumentList.Add("Bypass");
psi.ArgumentList.Add("-File");
psi.ArgumentList.Add(manifest.EntryFullPath);
// Files are passed both as a -Files parameter (array binding) and via an environment
// variable so scripts can consume whichever is convenient.
if (files.Count > 0)
{
psi.ArgumentList.Add("-Files");
foreach (var file in files)
{
psi.ArgumentList.Add(file);
}
psi.Environment[FilesEnvironmentVariable] = string.Join('\n', files);
}
if (parameters is not null)
{
foreach (var (name, value) in parameters)
{
psi.ArgumentList.Add("-" + name);
psi.ArgumentList.Add(value ?? string.Empty);
}
}
using var process = new Process { StartInfo = psi };
process.Start();
// Read both streams concurrently to avoid pipe deadlock on large output.
var stdOutTask = process.StandardOutput.ReadToEndAsync();
var stdErrTask = process.StandardError.ReadToEndAsync();
process.WaitForExit();
return new ScriptExecutionResult
{
ExitCode = process.ExitCode,
StdOut = stdOutTask.GetAwaiter().GetResult(),
StdErr = stdErrTask.GetAwaiter().GetResult(),
};
}
/// <summary>
/// Prefers PowerShell 7+ (<c>pwsh</c>); falls back to Windows PowerShell (<c>powershell</c>).
/// </summary>
private static string ResolvePowerShellExecutable()
{
return ExistsOnPath("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
}
private static bool ExistsOnPath(string fileName)
{
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
try
{
if (File.Exists(Path.Combine(dir.Trim(), fileName)))
{
return true;
}
}
catch
{
// Ignore malformed PATH entries.
}
}
return false;
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowerScripts.Core.Manifest;
/// <summary>
/// Centralized JSON options and (de)serialization helpers for PowerScript manifests.
/// </summary>
public static class ManifestSerializer
{
public static JsonSerializerOptions Options { get; } = CreateOptions();
private static JsonSerializerOptions CreateOptions()
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
public static PowerScriptManifest? Deserialize(string json) =>
JsonSerializer.Deserialize<PowerScriptManifest>(json, Options);
public static string Serialize(PowerScriptManifest manifest) =>
JsonSerializer.Serialize(manifest, Options);
}

View File

@@ -0,0 +1,62 @@
// 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 PowerScripts.Core.Manifest;
/// <summary>
/// Validates a parsed manifest. Returns human-readable errors rather than throwing so the registry
/// can skip a single bad script without failing the whole catalogue.
///
/// A script's <c>id</c> is its portable identity and is intentionally decoupled from the folder it
/// happens to live in: this lets a script keep a stable id when it is shared, downloaded from a
/// community catalogue, or dropped into a differently-named folder to avoid a local name clash.
/// Uniqueness of ids across the catalogue is enforced by the registry, not here.
/// </summary>
public static class ManifestValidator
{
public static IReadOnlyList<string> Validate(PowerScriptManifest manifest, string folderName)
{
_ = folderName;
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(manifest.Id))
{
errors.Add("'id' is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Name))
{
errors.Add("'name' is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Entry))
{
errors.Add("'entry' is required.");
}
else if (!string.IsNullOrEmpty(manifest.FolderPath) && !File.Exists(manifest.EntryFullPath))
{
errors.Add($"entry script not found: '{manifest.Entry}'.");
}
if (manifest.Kind == ScriptKind.File)
{
if (manifest.Input is null || manifest.Input.Extensions.Count == 0)
{
errors.Add("file scripts must declare 'input.extensions'.");
}
if (manifest.Input is { MinFiles: < 1 })
{
errors.Add("'input.minFiles' must be at least 1.");
}
if (manifest.Input is { MaxFiles: > 0 } input && input.MaxFiles < input.MinFiles)
{
errors.Add("'input.maxFiles' must be 0 (unbounded) or >= minFiles.");
}
}
return errors;
}
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace PowerScripts.Core.Manifest;
/// <summary>
/// What a PowerScript operates on.
/// </summary>
public enum ScriptKind
{
/// <summary>Acts on the PC; no file input. Surfaced via hotkey / Command Palette.</summary>
System,
/// <summary>Acts on one or more input files of a declared type. Surfaced in the right-click menu.</summary>
File,
}
/// <summary>
/// The runtime used to execute a PowerScript. Only PowerShell is supported in the prototype;
/// the field exists so Python / Node can be added without a schema break.
/// </summary>
public enum ScriptRuntime
{
PowerShell,
}
/// <summary>
/// The kind of result a file PowerScript produces.
/// </summary>
public enum ScriptOutputType
{
None,
/// <summary>Produces a converted file (e.g. HEIC -> JPG).</summary>
ConvertedFile,
/// <summary>Performs a side effect (e.g. checksum, OCR, strip metadata).</summary>
SideEffect,
}
/// <summary>
/// Declares the file input contract for a <see cref="ScriptKind.File"/> script.
/// </summary>
public sealed class ScriptInput
{
/// <summary>File extensions this script accepts (e.g. ".heic"). "*" means any extension.</summary>
public List<string> Extensions { get; set; } = new();
/// <summary>Minimum number of files required.</summary>
public int MinFiles { get; set; } = 1;
/// <summary>Maximum number of files; 0 means unbounded.</summary>
public int MaxFiles { get; set; }
}
/// <summary>
/// Declares the output contract for a <see cref="ScriptKind.File"/> script.
/// </summary>
public sealed class ScriptOutput
{
public ScriptOutputType Type { get; set; } = ScriptOutputType.None;
/// <summary>For <see cref="ScriptOutputType.ConvertedFile"/>: the produced extension (e.g. ".jpg").</summary>
public string? Extension { get; set; }
}
/// <summary>
/// A typed, user-editable parameter passed to the script.
/// </summary>
public sealed class ScriptParameter
{
public string Name { get; set; } = string.Empty;
/// <summary>One of: "string", "int", "bool".</summary>
public string Type { get; set; } = "string";
public string? Default { get; set; }
public int? Min { get; set; }
public int? Max { get; set; }
}
/// <summary>
/// The on-disk description of a single PowerScript. One script lives in its own folder containing
/// a <c>manifest.json</c> (this type) plus the script body referenced by <see cref="Entry"/>.
/// </summary>
public sealed class PowerScriptManifest
{
public int SchemaVersion { get; set; } = 1;
/// <summary>Stable identifier; must match the containing folder name.</summary>
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
/// <summary>Optional icon file name, relative to the script folder.</summary>
public string? Icon { get; set; }
/// <summary>Optional author/publisher, shown in the trust prompt (e.g. "contoso" or a GitHub user).</summary>
public string? Publisher { get; set; }
/// <summary>Optional semantic version of the script (e.g. "1.2.0").</summary>
public string? Version { get; set; }
/// <summary>Optional provenance, e.g. the catalogue URL the script was adopted from.</summary>
public string? Source { get; set; }
public ScriptKind Kind { get; set; }
public ScriptRuntime Runtime { get; set; } = ScriptRuntime.PowerShell;
/// <summary>Script body file name, relative to the script folder (e.g. "run.ps1").</summary>
public string Entry { get; set; } = string.Empty;
/// <summary>File input contract; required for <see cref="ScriptKind.File"/>.</summary>
public ScriptInput? Input { get; set; }
public ScriptOutput? Output { get; set; }
public List<ScriptParameter> Parameters { get; set; } = new();
/// <summary>Where the script appears, e.g. "contextMenu", "keyboardManager", "commandPalette".</summary>
public List<string> Surfaces { get; set; } = new();
/// <summary>
/// Declared capabilities (e.g. "fileRead", "fileWrite", "process"). Doubles as the user-consent
/// string and the permission contract an agent / MCP server must respect.
/// </summary>
public List<string> Capabilities { get; set; } = new();
/// <summary>Prototype always runs "asInvoker" (non-elevated).</summary>
public string Elevation { get; set; } = "asInvoker";
/// <summary>Absolute path to the folder that contains this manifest. Populated by the registry.</summary>
[JsonIgnore]
public string FolderPath { get; set; } = string.Empty;
/// <summary>Absolute path to the script body file.</summary>
[JsonIgnore]
public string EntryFullPath => string.IsNullOrEmpty(FolderPath) ? Entry : Path.Combine(FolderPath, Entry);
/// <summary>True if this script declares the given surface (case-insensitive).</summary>
public bool HasSurface(string surface) =>
Surfaces.Any(s => string.Equals(s, surface, StringComparison.OrdinalIgnoreCase));
}

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>PowerScripts.Core</RootNamespace>
<AssemblyName>PowerScripts.Core</AssemblyName>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,114 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
namespace PowerScripts.Core;
/// <summary>
/// Well-known filesystem locations for the PowerScripts module. The scripts root can be overridden
/// (explicit path, environment variable, or a persisted user setting) which keeps tests and ad-hoc
/// runs hermetic and lets the user point PowerScripts at their own folder from Settings.
/// </summary>
public static class PowerScriptsPaths
{
/// <summary>Environment variable that overrides the default scripts root.</summary>
public const string RootEnvironmentVariable = "POWERSCRIPTS_ROOT";
/// <summary>The folder a single script lives in must contain a file with this name.</summary>
public const string ManifestFileName = "manifest.json";
/// <summary>The user-settings file name persisted next to the module data.</summary>
public const string ConfigFileName = "config.json";
/// <summary>
/// The module's data directory: <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts</c>.
/// </summary>
public static string ModuleDirectory
{
get
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "Microsoft", "PowerToys", "PowerScripts");
}
}
/// <summary>The user-settings file that persists the chosen scripts root.</summary>
public static string ConfigFilePath => Path.Combine(ModuleDirectory, ConfigFileName);
/// <summary>The trust store file name (records which script contents the user has approved).</summary>
public const string TrustFileName = "trust.json";
/// <summary>The trust store: which (script id, content hash) pairs the user has approved to run.</summary>
public static string TrustFilePath => Path.Combine(ModuleDirectory, TrustFileName);
/// <summary>
/// Default scripts root:
/// <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts\scripts</c>.
/// </summary>
public static string DefaultScriptsRoot => Path.Combine(ModuleDirectory, "scripts");
/// <summary>
/// Resolves the scripts root, honoring (in order): an explicit path, the environment override,
/// the persisted user setting, then the default.
/// </summary>
public static string ResolveScriptsRoot(string? explicitRoot = null)
{
if (!string.IsNullOrWhiteSpace(explicitRoot))
{
return explicitRoot;
}
var fromEnv = Environment.GetEnvironmentVariable(RootEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv;
}
var fromConfig = ReadConfiguredScriptsRoot();
return string.IsNullOrWhiteSpace(fromConfig) ? DefaultScriptsRoot : fromConfig;
}
/// <summary>
/// Reads the user-chosen scripts root from <see cref="ConfigFilePath"/>, or <c>null</c> if it is
/// missing, empty, or unreadable.
/// </summary>
public static string? ReadConfiguredScriptsRoot()
{
try
{
if (!File.Exists(ConfigFilePath))
{
return null;
}
using var stream = File.OpenRead(ConfigFilePath);
using var document = JsonDocument.Parse(stream);
if (document.RootElement.TryGetProperty("scriptsRoot", out var value) &&
value.ValueKind == JsonValueKind.String)
{
var root = value.GetString();
return string.IsNullOrWhiteSpace(root) ? null : root;
}
}
catch (Exception)
{
// A corrupt or unreadable config simply falls back to the default.
}
return null;
}
/// <summary>
/// Persists the user-chosen scripts root to <see cref="ConfigFilePath"/>. Passing <c>null</c> or
/// whitespace clears the override so the default is used again.
/// </summary>
public static void SaveConfiguredScriptsRoot(string? root)
{
Directory.CreateDirectory(ModuleDirectory);
var normalized = string.IsNullOrWhiteSpace(root) ? string.Empty : root.Trim();
var json = JsonSerializer.Serialize(new { scriptsRoot = normalized }, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigFilePath, json);
}
}

View File

@@ -0,0 +1,156 @@
// 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 PowerScripts.Core.Manifest;
namespace PowerScripts.Core.Registry;
/// <summary>
/// A manifest that failed to load or validate, kept so the UI can surface problems.
/// </summary>
public sealed record ScriptLoadError(string FolderPath, string Message);
/// <summary>
/// The single source of truth for installed PowerScripts. Every surface (context menu, Keyboard
/// Manager editor, Command Palette, agents) reads from this registry rather than defining scripts
/// of its own. The registry only reads the filesystem; it never executes anything.
/// </summary>
public sealed class ScriptRegistry
{
private readonly List<PowerScriptManifest> _scripts = new();
private readonly List<ScriptLoadError> _errors = new();
public ScriptRegistry(string? root = null)
{
Root = PowerScriptsPaths.ResolveScriptsRoot(root);
}
/// <summary>Absolute path to the scanned scripts root.</summary>
public string Root { get; }
public IReadOnlyList<PowerScriptManifest> Scripts => _scripts;
public IReadOnlyList<ScriptLoadError> Errors => _errors;
/// <summary>
/// Scans <see cref="Root"/> for <c>&lt;id&gt;/manifest.json</c> folders, parses and validates each,
/// and rebuilds the in-memory catalogue. Bad scripts are recorded in <see cref="Errors"/> and skipped.
/// </summary>
public void Load()
{
_scripts.Clear();
_errors.Clear();
if (!Directory.Exists(Root))
{
return;
}
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var folder in Directory.EnumerateDirectories(Root))
{
var manifestPath = Path.Combine(folder, PowerScriptsPaths.ManifestFileName);
if (!File.Exists(manifestPath))
{
continue;
}
PowerScriptManifest? manifest;
try
{
manifest = ManifestSerializer.Deserialize(File.ReadAllText(manifestPath));
}
catch (Exception ex)
{
_errors.Add(new ScriptLoadError(folder, $"failed to parse manifest.json: {ex.Message}"));
continue;
}
if (manifest is null)
{
_errors.Add(new ScriptLoadError(folder, "manifest.json deserialized to null."));
continue;
}
manifest.FolderPath = folder;
var folderName = new DirectoryInfo(folder).Name;
var validationErrors = ManifestValidator.Validate(manifest, folderName);
if (validationErrors.Count > 0)
{
_errors.Add(new ScriptLoadError(folder, string.Join(" ", validationErrors)));
continue;
}
// Ids are the portable identity and must be unique across the catalogue, since every
// surface resolves a script by id. A collision (e.g. two adopted scripts sharing an id)
// is reported and the duplicate skipped rather than silently shadowed.
if (!seenIds.Add(manifest.Id))
{
_errors.Add(new ScriptLoadError(folder, $"duplicate id '{manifest.Id}' - already defined by another script; skipped."));
continue;
}
_scripts.Add(manifest);
}
_scripts.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
}
public PowerScriptManifest? Get(string id) =>
_scripts.FirstOrDefault(s => string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase));
/// <summary>System scripts (no file input) — candidates for Keyboard Manager / Command Palette.</summary>
public IEnumerable<PowerScriptManifest> SystemScripts =>
_scripts.Where(s => s.Kind == ScriptKind.System);
/// <summary>
/// File scripts whose declared input extensions match the given file extension (e.g. ".png").
/// A declared extension of "*" matches anything. Used to build the right-click submenu.
/// </summary>
public IEnumerable<PowerScriptManifest> FileScriptsFor(string extension)
{
var ext = NormalizeExtension(extension);
return _scripts.Where(s =>
s.Kind == ScriptKind.File &&
s.Input is not null &&
s.Input.Extensions.Any(e => MatchesExtension(e, ext)));
}
/// <summary>
/// File scripts that accept <em>all</em> of the given files (every extension matches and the
/// count is within the declared min/max). Used when a multi-file selection is right-clicked.
/// </summary>
public IEnumerable<PowerScriptManifest> FileScriptsForSelection(IReadOnlyCollection<string> files)
{
var extensions = files.Select(f => NormalizeExtension(Path.GetExtension(f))).Distinct().ToList();
return _scripts.Where(s =>
s.Kind == ScriptKind.File &&
s.Input is not null &&
extensions.All(ext => s.Input.Extensions.Any(e => MatchesExtension(e, ext))) &&
files.Count >= s.Input.MinFiles &&
(s.Input.MaxFiles == 0 || files.Count <= s.Input.MaxFiles));
}
private static string NormalizeExtension(string extension)
{
if (string.IsNullOrEmpty(extension))
{
return string.Empty;
}
return extension.StartsWith('.') ? extension.ToLowerInvariant() : "." + extension.ToLowerInvariant();
}
private static bool MatchesExtension(string declared, string normalizedTarget)
{
if (declared == "*")
{
return true;
}
return string.Equals(NormalizeExtension(declared), normalizedTarget, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,50 @@
// 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.Security.Cryptography;
using System.Text;
using PowerScripts.Core.Manifest;
namespace PowerScripts.Core.Security;
/// <summary>
/// Computes a stable content fingerprint for a script. The fingerprint covers both the executable
/// body and the parts of the manifest that define what the script is allowed to do, so that editing
/// the script <em>or</em> escalating its declared capabilities invalidates any prior user trust and
/// forces a fresh consent prompt (trust-on-first-use).
/// </summary>
public static class ScriptIntegrity
{
/// <summary>
/// Returns the lowercase hex SHA-256 of the script's entry-file bytes combined with its declared
/// <c>kind</c> and (sorted) <c>capabilities</c>. Returns an empty string if the entry file is
/// missing (an untrusted state that will never match a stored trust record).
/// </summary>
public static string ComputeHash(PowerScriptManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
var entryPath = manifest.EntryFullPath;
if (string.IsNullOrEmpty(entryPath) || !File.Exists(entryPath))
{
return string.Empty;
}
var body = File.ReadAllBytes(entryPath);
var capabilities = manifest.Capabilities
.Select(c => c.Trim().ToLowerInvariant())
.Where(c => c.Length > 0)
.OrderBy(c => c, StringComparer.Ordinal);
var declaration = $"\nkind={manifest.Kind}\ncapabilities={string.Join(',', capabilities)}\n";
using var sha = SHA256.Create();
sha.TransformBlock(body, 0, body.Length, null, 0);
var declarationBytes = Encoding.UTF8.GetBytes(declaration);
sha.TransformFinalBlock(declarationBytes, 0, declarationBytes.Length);
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,125 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowerScripts.Core.Security;
/// <summary>
/// A single trust-on-first-use record: the user approved a script id whose content matched
/// <see cref="Hash"/>. If the script's content or declared capabilities later change, the recomputed
/// hash no longer matches and the user is asked to approve again.
/// </summary>
public sealed class TrustRecord
{
public string Id { get; set; } = string.Empty;
public string Hash { get; set; } = string.Empty;
public IReadOnlyList<string> Capabilities { get; set; } = [];
public string? Source { get; set; }
public string? Publisher { get; set; }
public DateTimeOffset ApprovedUtc { get; set; }
}
/// <summary>
/// Persists which script contents the user has explicitly allowed to run. This is the enforcement
/// point behind the manifest's declared <c>capabilities</c>: a script only runs once the user has
/// approved its exact current content, and re-approves whenever that content changes.
/// </summary>
public sealed class TrustStore
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly string _path;
private readonly Dictionary<string, TrustRecord> _records;
public TrustStore(string path)
{
_path = path ?? throw new ArgumentNullException(nameof(path));
_records = Load(path);
}
/// <summary>All current trust records.</summary>
public IReadOnlyCollection<TrustRecord> Records => _records.Values;
/// <summary>Returns true if the user has approved this id with exactly this content hash.</summary>
public bool IsTrusted(string id, string hash)
{
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(hash))
{
return false;
}
return _records.TryGetValue(id, out var record)
&& string.Equals(record.Hash, hash, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Records (or updates) approval for an id at the given content hash and persists it.</summary>
public void Trust(TrustRecord record)
{
ArgumentNullException.ThrowIfNull(record);
_records[record.Id] = record;
Save();
}
/// <summary>Removes approval for an id. Returns true if a record was removed.</summary>
public bool Revoke(string id)
{
if (string.IsNullOrEmpty(id) || !_records.Remove(id))
{
return false;
}
Save();
return true;
}
private static Dictionary<string, TrustRecord> Load(string path)
{
var result = new Dictionary<string, TrustRecord>(StringComparer.OrdinalIgnoreCase);
try
{
if (File.Exists(path))
{
var records = JsonSerializer.Deserialize<List<TrustRecord>>(File.ReadAllText(path), Options);
if (records is not null)
{
foreach (var record in records.Where(r => !string.IsNullOrEmpty(r.Id)))
{
result[record.Id] = record;
}
}
}
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
// A corrupt or unreadable trust file is treated as "nothing trusted" so the user is
// simply re-prompted, rather than crashing every surface that runs a script.
}
return result;
}
private void Save()
{
var directory = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_path, JsonSerializer.Serialize(_records.Values.ToList(), Options));
}
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using PowerScripts.Core.Manifest;
namespace PowerScripts.Host;
/// <summary>
/// Shows the trust-on-first-use consent dialog. Because every surface (context menu, Keyboard
/// Manager, agents) funnels through <c>Host run &lt;id&gt;</c>, this single prompt is the one place a
/// user sees, in plain language, exactly what a script is and what it declares it can do before it
/// ever executes. A native top-most MessageBox is used so the prompt is visible even when the Host
/// was launched hidden by a surface.
/// </summary>
internal static class ConsentPrompt
{
private const uint MB_YESNO = 0x00000004;
private const uint MB_ICONWARNING = 0x00000030;
private const uint MB_DEFBUTTON2 = 0x00000100;
private const uint MB_TOPMOST = 0x00040000;
private const uint MB_SETFOREGROUND = 0x00010000;
private const int IDYES = 6;
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
/// <summary>
/// Returns true if the user approves running this script. Presents the script's identity,
/// provenance and declared capabilities so the decision is informed.
/// </summary>
public static bool Confirm(PowerScriptManifest manifest)
{
var capabilities = manifest.Capabilities.Count > 0
? string.Join(", ", manifest.Capabilities)
: "(none declared)";
var publisher = string.IsNullOrWhiteSpace(manifest.Publisher) ? "(unknown)" : manifest.Publisher;
var source = string.IsNullOrWhiteSpace(manifest.Source) ? "(local)" : manifest.Source;
var text =
$"A PowerScript is about to run for the first time (or its contents changed).\n\n" +
$"Name: {manifest.Name}\n" +
$"Id: {manifest.Id}\n" +
$"Publisher: {publisher}\n" +
$"Source: {source}\n" +
$"Runtime: {manifest.Runtime}\n" +
$"Declares: {capabilities}\n" +
$"Script file: {manifest.EntryFullPath}\n\n" +
"Only allow scripts you trust. Allow this script to run?";
var result = MessageBoxW(
IntPtr.Zero,
text,
"PowerScripts — allow this script to run?",
MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2 | MB_TOPMOST | MB_SETFOREGROUND);
return result == IDYES;
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>PowerScripts.Host</RootNamespace>
<AssemblyName>PowerScripts.Host</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PowerScripts.Core\PowerScripts.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,481 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using PowerScripts.Core;
using PowerScripts.Core.Execution;
using PowerScripts.Core.Manifest;
using PowerScripts.Core.Registry;
using PowerScripts.Core.Security;
namespace PowerScripts.Host;
/// <summary>
/// The shared PowerScripts executor / catalogue CLI.
///
/// This is the single invocation entry point every surface points at:
/// - Keyboard Manager maps a hotkey to: PowerScripts.Host.exe run &lt;id&gt;
/// - The Explorer context menu invokes: PowerScripts.Host.exe run &lt;id&gt; --files &lt;paths&gt;
/// - The KBM editor / agents enumerate via: PowerScripts.Host.exe list --json
///
/// Usage:
/// PowerScripts.Host list [--json] [--root &lt;dir&gt;]
/// PowerScripts.Host run &lt;id&gt; [--files &lt;f1&gt; &lt;f2&gt; ...] [--set name=value ...] [--root &lt;dir&gt;]
/// </summary>
internal static class Program
{
private static int Main(string[] args)
{
try
{
if (args.Length == 0)
{
PrintUsage();
return 1;
}
var (positional, options) = ParseArgs(args.Skip(1).ToArray());
var root = options.TryGetValue("root", out var r) ? r.FirstOrDefault() : null;
var registry = new ScriptRegistry(root);
registry.Load();
return args[0].ToLowerInvariant() switch
{
"list" => RunList(registry, options.ContainsKey("json")),
"run" => RunScript(registry, positional, options),
"trust" => RunTrust(registry, positional),
"kbm" => RunKbm(registry, positional, options.ContainsKey("json")),
"set-extensions" => RunSetExtensions(registry, positional, options),
"shell-menu" => RunShellMenu(registry, options),
"shell-install" => ShellRegistration.Install(registry, Environment.ProcessPath ?? "PowerScripts.Host.exe"),
"shell-uninstall" => ShellRegistration.Uninstall(registry),
"-h" or "--help" or "help" => PrintUsage(),
_ => Unknown(args[0]),
};
}
catch (Exception ex)
{
Console.Error.WriteLine($"PowerScripts error: {ex.Message}");
return 2;
}
}
private static int RunList(ScriptRegistry registry, bool asJson)
{
if (asJson)
{
// Structured, permissioned capability list — also the shape the KBM editor picker and
// future agents/MCP servers consume.
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
var projection = registry.Scripts.Select(s => new
{
s.Id,
s.Name,
s.Description,
kind = s.Kind.ToString(),
runtime = s.Runtime.ToString(),
s.Publisher,
s.Version,
s.Source,
s.Surfaces,
s.Capabilities,
trusted = trustStore.IsTrusted(s.Id, ScriptIntegrity.ComputeHash(s)),
input = s.Input,
parameters = s.Parameters,
});
Console.WriteLine(JsonSerializer.Serialize(
projection,
new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
return 0;
}
Console.WriteLine($"Scripts root: {registry.Root}");
if (registry.Scripts.Count == 0)
{
Console.WriteLine("(no scripts found)");
}
foreach (var s in registry.Scripts)
{
Console.WriteLine($" {s.Id,-24} [{s.Kind,-6}] {s.Name}");
}
foreach (var e in registry.Errors)
{
Console.Error.WriteLine($" ! {e.FolderPath}: {e.Message}");
}
return 0;
}
private static int RunScript(
ScriptRegistry registry,
IReadOnlyList<string> positional,
IReadOnlyDictionary<string, List<string>> options)
{
if (positional.Count == 0)
{
Console.Error.WriteLine("run: missing <id>.");
return 1;
}
var id = positional[0];
var manifest = registry.Get(id);
if (manifest is null)
{
Console.Error.WriteLine($"run: no script with id '{id}'. Try 'list'.");
return 1;
}
var files = options.TryGetValue("files", out var f) ? f : new List<string>();
// Trust-on-first-use gate. This is the single enforcement point for the manifest's declared
// capabilities: a script only runs once the user has approved its exact current content, and
// is re-prompted whenever the script body or its declared capabilities change (the content
// hash then no longer matches the stored approval).
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
var contentHash = ScriptIntegrity.ComputeHash(manifest);
if (!trustStore.IsTrusted(id, contentHash))
{
var nonInteractive = options.ContainsKey("no-consent")
|| string.Equals(Environment.GetEnvironmentVariable("POWERSCRIPTS_NO_CONSENT"), "1", StringComparison.Ordinal);
if (nonInteractive)
{
Console.Error.WriteLine($"run: script '{id}' is not trusted and consent is disabled; refusing to run. Approve it with 'trust approve {id}'.");
return 3;
}
if (!ConsentPrompt.Confirm(manifest))
{
Console.Error.WriteLine($"run: user declined to trust script '{id}'.");
return 3;
}
trustStore.Trust(new TrustRecord
{
Id = manifest.Id,
Hash = contentHash,
Capabilities = manifest.Capabilities,
Source = manifest.Source,
Publisher = manifest.Publisher,
ApprovedUtc = DateTimeOffset.UtcNow,
});
}
var parameters = new Dictionary<string, string?>();
if (options.TryGetValue("set", out var sets))
{
foreach (var kv in sets)
{
var idx = kv.IndexOf('=');
if (idx <= 0)
{
Console.Error.WriteLine($"run: --set expects name=value, got '{kv}'.");
return 1;
}
parameters[kv[..idx]] = kv[(idx + 1)..];
}
}
var executor = new ScriptExecutor();
var result = executor.Execute(manifest, files, parameters);
if (!string.IsNullOrEmpty(result.StdOut))
{
Console.Out.Write(result.StdOut);
}
if (!string.IsNullOrEmpty(result.StdErr))
{
Console.Error.Write(result.StdErr);
}
return result.ExitCode;
}
/// <summary>
/// Manages the trust store — the record of which script contents the user has approved to run.
/// trust list show every approved script id + the content hash approved
/// trust approve &lt;id&gt; approve the script's current content without running it
/// trust revoke &lt;id&gt; forget approval, so the next run re-prompts
/// </summary>
private static int RunTrust(ScriptRegistry registry, IReadOnlyList<string> positional)
{
var sub = positional.Count > 0 ? positional[0].ToLowerInvariant() : "list";
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
switch (sub)
{
case "list":
if (trustStore.Records.Count == 0)
{
Console.WriteLine("(no scripts trusted yet)");
return 0;
}
foreach (var record in trustStore.Records.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine($" {record.Id,-24} {record.Hash[..Math.Min(12, record.Hash.Length)]} approved {record.ApprovedUtc:u}");
}
return 0;
case "approve":
{
if (positional.Count < 2)
{
Console.Error.WriteLine("trust approve: missing <id>.");
return 1;
}
var manifest = registry.Get(positional[1]);
if (manifest is null)
{
Console.Error.WriteLine($"trust approve: no script with id '{positional[1]}'. Try 'list'.");
return 1;
}
trustStore.Trust(new TrustRecord
{
Id = manifest.Id,
Hash = ScriptIntegrity.ComputeHash(manifest),
Capabilities = manifest.Capabilities,
Source = manifest.Source,
Publisher = manifest.Publisher,
ApprovedUtc = DateTimeOffset.UtcNow,
});
Console.WriteLine($"trust approve: '{manifest.Id}' approved.");
return 0;
}
case "revoke":
if (positional.Count < 2)
{
Console.Error.WriteLine("trust revoke: missing <id>.");
return 1;
}
if (trustStore.Revoke(positional[1]))
{
Console.WriteLine($"trust revoke: '{positional[1]}' will be re-prompted on next run.");
return 0;
}
Console.Error.WriteLine($"trust revoke: '{positional[1]}' was not trusted.");
return 1;
default:
Console.Error.WriteLine($"trust: unknown subcommand '{sub}'. Use list | approve <id> | revoke <id>.");
return 1;
}
}
/// <summary>
/// Emits the Keyboard Manager "Run Program" mapping for a system PowerScript so a user (or the
/// future KBM editor picker) can bind a hotkey to it. KBM's existing RunProgram action already
/// supports this — no KBM engine change is needed. The app path + args go straight into the
/// editor's "Run Program" fields; <c>--json</c> emits the on-disk mapping shape (the user still
/// chooses the trigger keys, so <c>originalKeys</c> is left as a placeholder).
/// </summary>
private static int RunKbm(ScriptRegistry registry, IReadOnlyList<string> positional, bool asJson)
{
if (positional.Count == 0)
{
Console.Error.WriteLine("kbm: missing <id>.");
return 1;
}
var manifest = registry.Get(positional[0]);
if (manifest is null)
{
Console.Error.WriteLine($"kbm: no script with id '{positional[0]}'. Try 'list'.");
return 1;
}
var hostPath = Environment.ProcessPath ?? "PowerScripts.Host.exe";
var programArgs = $"run {manifest.Id}";
if (asJson)
{
// Field names match the KBM engine (see common/KeyboardManagerConstants.h /
// MappingConfiguration.cpp). Append this to remapShortcutsToRunProgram and set
// originalKeys to your chosen trigger (e.g. "162;91;83" for Ctrl+Win+S).
var mapping = new Dictionary<string, object>
{
["originalKeys"] = "<set-your-trigger-keys>",
["operationType"] = 1,
["runProgramFilePath"] = hostPath,
["runProgramArgs"] = programArgs,
["runProgramStartInDir"] = string.Empty,
["runProgramElevationLevel"] = 0,
["runProgramAlreadyRunningAction"] = 0,
["runProgramStartWindowType"] = 0,
["unicodeText"] = "*Unsupported*",
};
Console.WriteLine(JsonSerializer.Serialize(mapping, new JsonSerializerOptions { WriteIndented = true }));
return 0;
}
Console.WriteLine($"PowerScript '{manifest.Id}' ({manifest.Name}) — Keyboard Manager 'Run Program' action:");
Console.WriteLine($" Program: {hostPath}");
Console.WriteLine($" Arguments: {programArgs}");
Console.WriteLine();
Console.WriteLine("In Keyboard Manager: Remap a shortcut -> action 'Run Program', paste the values above,");
Console.WriteLine("then pick the trigger shortcut. (Use 'kbm <id> --json' for the raw mapping object.)");
return 0;
}
/// <summary>
/// Emits the file scripts that match a right-clicked selection as tab-separated
/// <c>&lt;id&gt;\t&lt;name&gt;</c> lines (one per script). This is the machine-readable feed the
/// Windows 11 modern context-menu handler (IExplorerCommand) consumes to build its submenu; a
/// line-based format keeps the native handler free of a JSON parser.
/// </summary>
private static int RunShellMenu(ScriptRegistry registry, IReadOnlyDictionary<string, List<string>> options)
{
var files = options.TryGetValue("files", out var f) ? f : new List<string>();
if (files.Count == 0)
{
return 0;
}
foreach (var script in registry.FileScriptsForSelection(files))
{
Console.WriteLine($"{script.Id}\t{script.Name}");
}
return 0;
}
/// <summary>
/// Rewrites a file script's declared input extensions in its manifest.json. This is the write
/// side of the Settings "trigger on these file types" editor; the user picks the extensions and
/// every surface (context menu, selection matching) then reflects them. System scripts have no
/// file input, so they are rejected.
/// </summary>
private static int RunSetExtensions(
ScriptRegistry registry,
IReadOnlyList<string> positional,
IReadOnlyDictionary<string, List<string>> options)
{
if (positional.Count == 0)
{
Console.Error.WriteLine("set-extensions: missing <id>.");
return 1;
}
var manifest = registry.Get(positional[0]);
if (manifest is null)
{
Console.Error.WriteLine($"set-extensions: no script with id '{positional[0]}'. Try 'list'.");
return 1;
}
if (manifest.Kind != ScriptKind.File)
{
Console.Error.WriteLine($"set-extensions: '{manifest.Id}' is a {manifest.Kind} script; extensions only apply to File scripts.");
return 1;
}
var raw = options.TryGetValue("ext", out var values) ? values : new List<string>();
var normalized = raw
.SelectMany(v => v.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries))
.Select(NormalizeExtension)
.Where(e => !string.IsNullOrEmpty(e))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count == 0)
{
Console.Error.WriteLine("set-extensions: at least one extension is required (e.g. --ext .md .txt).");
return 1;
}
manifest.Input ??= new ScriptInput();
manifest.Input.Extensions = normalized;
var manifestPath = Path.Combine(manifest.FolderPath, PowerScriptsPaths.ManifestFileName);
File.WriteAllText(manifestPath, ManifestSerializer.Serialize(manifest));
Console.WriteLine($"set-extensions: {manifest.Id} -> [{string.Join(", ", normalized)}]");
return 0;
}
/// <summary>Normalizes a user-typed extension to lower-case with a leading dot ("md" -> ".md").</summary>
private static string NormalizeExtension(string raw)
{
var e = raw.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(e) || e == "*")
{
return e;
}
return e.StartsWith('.') ? e : "." + e;
}
/// <summary>
/// Minimal parser. Recognizes <c>--name value [value ...]</c> (multi-value, e.g. --files) and
/// <c>--flag</c> (no value, e.g. --json). Everything else is positional.
/// </summary>
private static (List<string> Positional, Dictionary<string, List<string>> Options) ParseArgs(string[] args)
{
var positional = new List<string>();
var options = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
string? current = null;
foreach (var arg in args)
{
if (arg.StartsWith("--", StringComparison.Ordinal))
{
current = arg[2..];
if (!options.ContainsKey(current))
{
options[current] = new List<string>();
}
}
else if (current is not null)
{
options[current].Add(arg);
}
else
{
positional.Add(arg);
}
}
return (positional, options);
}
private static int Unknown(string command)
{
Console.Error.WriteLine($"Unknown command '{command}'.");
PrintUsage();
return 1;
}
private static int PrintUsage()
{
Console.WriteLine("PowerScripts.Host — run and enumerate PowerScripts.");
Console.WriteLine();
Console.WriteLine(" list [--json] [--root <dir>]");
Console.WriteLine(" run <id> [--files <f1> <f2> ...] [--set name=value ...] [--no-consent] [--root <dir>]");
Console.WriteLine(" trust list | approve <id> | revoke <id> (manage which scripts are allowed to run)");
Console.WriteLine(" kbm <id> [--json] [--root <dir>] (Keyboard Manager 'Run Program' mapping)");
Console.WriteLine(" set-extensions <id> --ext <.md .txt ...> (set a file script's trigger extensions)");
Console.WriteLine(" shell-menu --files <f1> <f2> ... (tab-separated id/name of matching file scripts)");
Console.WriteLine(" shell-install [--root <dir>] (register the Explorer right-click submenu)");
Console.WriteLine(" shell-uninstall [--root <dir>] (remove the Explorer right-click submenu)");
return 0;
}
}

View File

@@ -0,0 +1,134 @@
// 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.Win32;
using PowerScripts.Core.Manifest;
using PowerScripts.Core.Registry;
namespace PowerScripts.Host;
/// <summary>
/// Registers / unregisters the Explorer right-click "PowerScript" cascading submenu for file
/// PowerScripts. For each file extension declared by a script, it writes a per-user shell verb under
/// <c>HKCU\Software\Classes\SystemFileAssociations\&lt;ext&gt;\shell\PowerScripts</c> whose nested
/// sub-verbs (one per matching script) invoke <c>PowerScripts.Host.exe run &lt;id&gt; --files "%1"</c>.
///
/// This is the prototype's context-menu surface: it needs no COM DLL and is driven entirely by the
/// script registry, so right-click works immediately and reflects the installed scripts. The
/// PowerScripts module (runner) calls <c>shell-install</c> on enable and <c>shell-uninstall</c> on
/// disable.
/// </summary>
internal static class ShellRegistration
{
private const string RootVerb = "PowerScripts";
private const string MenuLabel = "PowerScript";
private const string ClassesRoot = @"Software\Classes\SystemFileAssociations";
/// <summary>Marker value so uninstall only removes keys this tool created.</summary>
private const string OwnerMarkerName = "PowerScriptsOwned";
public static int Install(ScriptRegistry registry, string hostExePath)
{
// Group file scripts by each declared extension (skip the "*" wildcard for the static menu).
var byExtension = new Dictionary<string, List<PowerScriptManifest>>(StringComparer.OrdinalIgnoreCase);
foreach (var script in registry.Scripts.Where(s => s.Kind == ScriptKind.File && s.Input is not null))
{
foreach (var rawExt in script.Input!.Extensions)
{
if (rawExt == "*")
{
continue;
}
var ext = rawExt.StartsWith('.') ? rawExt : "." + rawExt;
if (!byExtension.TryGetValue(ext, out var list))
{
list = new List<PowerScriptManifest>();
byExtension[ext] = list;
}
list.Add(script);
}
}
if (byExtension.Count == 0)
{
Console.WriteLine("shell-install: no file scripts with concrete extensions to register.");
return 0;
}
foreach (var (ext, scripts) in byExtension)
{
RemoveVerbForExtension(ext);
var verbPath = $@"{ClassesRoot}\{ext}\shell\{RootVerb}";
using var verbKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(verbPath)!;
verbKey.SetValue("MUIVerb", MenuLabel);
verbKey.SetValue(OwnerMarkerName, 1, RegistryValueKind.DWord);
// Presence of "SubCommands" makes Explorer render the nested \shell verbs as a submenu.
verbKey.SetValue("SubCommands", string.Empty);
using var subShell = verbKey.CreateSubKey("shell")!;
foreach (var script in scripts)
{
using var item = subShell.CreateSubKey(script.Id)!;
item.SetValue("MUIVerb", script.Name);
using var command = item.CreateSubKey("command")!;
command.SetValue(null, $"\"{hostExePath}\" run {script.Id} --files \"%1\"");
}
Console.WriteLine($" registered {scripts.Count} script(s) for {ext}");
}
Console.WriteLine($"shell-install: done ({byExtension.Count} extension(s)).");
return 0;
}
public static int Uninstall(ScriptRegistry registry)
{
// Remove for every extension currently declared, plus best-effort sweep is unnecessary since
// we only ever create owned keys.
var extensions = registry.Scripts
.Where(s => s.Kind == ScriptKind.File && s.Input is not null)
.SelectMany(s => s.Input!.Extensions)
.Where(e => e != "*")
.Select(e => e.StartsWith('.') ? e : "." + e)
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var ext in extensions)
{
RemoveVerbForExtension(ext);
}
Console.WriteLine("shell-uninstall: done.");
return 0;
}
private static void RemoveVerbForExtension(string ext)
{
var verbParent = $@"{ClassesRoot}\{ext}\shell";
using var shellKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(verbParent, writable: true);
if (shellKey is null)
{
return;
}
// Only delete the verb if we own it.
using (var verbKey = shellKey.OpenSubKey(RootVerb))
{
if (verbKey is null)
{
return;
}
if (verbKey.GetValue(OwnerMarkerName) is null)
{
return;
}
}
shellKey.DeleteSubKeyTree(RootVerb, throwOnMissingSubKey: false);
}
}

View File

@@ -0,0 +1,9 @@
# Native handler build artifacts
*.dll
*.lib
*.exp
*.obj
*.pdb
*.ilk
# Host publish output used by register.ps1
hostpublish/

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
IgnorableNamespaces="uap rescap desktop4 desktop5 uap10 com">
<Identity Name="Microsoft.PowerToys.PowerScriptsContextMenu" ProcessorArchitecture="neutral" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" Version="1.0.0.0" />
<Properties>
<DisplayName>PowerToys PowerScripts Context Menu</DisplayName>
<PublisherDisplayName>Microsoft</PublisherDisplayName>
<Logo>Assets\storelogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.18950.0" MaxVersionTested="10.0.19000.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="unvirtualizedResources" />
</Capabilities>
<Applications>
<Application Id="PowerScriptsContextMenu" Executable="PowerScripts.Host.exe" uap10:TrustLevel="mediumIL" uap10:RuntimeBehavior="win32App">
<uap:VisualElements AppListEntry="none" DisplayName="PowerToys PowerScripts Context Menu" Description="PowerScripts context menu handler" BackgroundColor="transparent" Square150x150Logo="Assets\Square150x150Logo.png" Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" Square310x310Logo="Assets\LargeTile.png" Square71x71Logo="Assets\SmallTile.png"></uap:DefaultTile>
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop4:Extension Category="windows.fileExplorerContextMenus">
<desktop4:FileExplorerContextMenus>
<desktop5:ItemType Type="*">
<desktop5:Verb Id="PowerScriptsCommand" Clsid="9FF7C126-9562-4F16-A6FB-9622B26E0D62" />
</desktop5:ItemType>
</desktop4:FileExplorerContextMenus>
</desktop4:Extension>
<com:Extension Category="windows.comServer" uap10:RuntimeBehavior="packagedClassicApp">
<com:ComServer>
<com:SurrogateServer DisplayName="PowerScripts context menu verb handler">
<com:Class Id="9FF7C126-9562-4F16-A6FB-9622B26E0D62" Path="PowerToys.PowerScriptsContextMenu.dll" ThreadingModel="STA" />
</com:SurrogateServer>
</com:ComServer>
</com:Extension>
</Extensions>
</Application>
</Applications>
</Package>

View File

@@ -0,0 +1,15 @@
@echo off
rem Builds the PowerScripts Windows 11 context-menu handler DLL (self-contained, no PowerToys deps).
setlocal
set "VCVARS=C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
if not exist "%VCVARS%" (
echo Could not find vcvars64.bat at "%VCVARS%". Edit build.cmd to point at your VS install.
exit /b 1
)
call "%VCVARS%" >nul || exit /b 1
cd /d "%~dp0"
cl /nologo /std:c++17 /EHsc /O2 /MT /DUNICODE /D_UNICODE /LD dllmain.cpp ^
/Fe:PowerToys.PowerScriptsContextMenu.dll ^
/link /DEF:dll.def shlwapi.lib runtimeobject.lib ole32.lib || exit /b 1
echo Built PowerToys.PowerScriptsContextMenu.dll
endlocal

View File

@@ -0,0 +1,4 @@
EXPORTS
DllCanUnloadNow PRIVATE
DllGetClassObject PRIVATE
DllGetActivationFactory PRIVATE

View File

@@ -0,0 +1,388 @@
// PowerScripts Windows 11 modern context-menu handler.
//
// A self-contained IExplorerCommand COM server (no PowerToys common dependencies). It surfaces a
// top-level "PowerScript" entry with a dynamic submenu of the file scripts that match the current
// selection. The actual matching/running logic lives in PowerScripts.Host.exe (deployed next to
// this DLL); the handler is a thin shell that:
// * GetState -> runs "Host shell-menu --files <paths>", caches the id/name lines, hides itself
// when nothing matches.
// * EnumSubCommands -> turns each cached line into a submenu item.
// * Invoke (item) -> runs "Host run <id> --files <paths>".
#include <windows.h>
#include <shobjidl_core.h>
#include <shlwapi.h>
#include <wrl/module.h>
#include <wrl/implements.h>
#include <wrl/client.h>
#include <string>
#include <vector>
using namespace Microsoft::WRL;
namespace
{
HMODULE g_hModule = nullptr;
long g_refModule = 0;
// Full path to PowerScripts.Host.exe, assumed to sit next to this DLL.
std::wstring FindHostExe()
{
wchar_t path[MAX_PATH] = {};
GetModuleFileNameW(g_hModule, path, ARRAYSIZE(path));
std::wstring dir(path);
const size_t slash = dir.find_last_of(L"\\/");
if (slash != std::wstring::npos)
{
dir.erase(slash + 1);
}
return dir + L"PowerScripts.Host.exe";
}
// Extracts the filesystem paths from a shell selection.
std::vector<std::wstring> ExtractPaths(IShellItemArray* selection)
{
std::vector<std::wstring> result;
if (selection == nullptr)
{
return result;
}
DWORD count = 0;
if (FAILED(selection->GetCount(&count)))
{
return result;
}
for (DWORD i = 0; i < count; ++i)
{
ComPtr<IShellItem> item;
if (FAILED(selection->GetItemAt(i, &item)))
{
continue;
}
PWSTR pszPath = nullptr;
if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &pszPath)) && pszPath != nullptr)
{
result.emplace_back(pszPath);
CoTaskMemFree(pszPath);
}
}
return result;
}
// Quotes a single command-line argument.
std::wstring Quote(const std::wstring& value)
{
return L"\"" + value + L"\"";
}
std::wstring BuildFilesArguments(const std::vector<std::wstring>& files)
{
std::wstring args;
for (const auto& file : files)
{
args += L" " + Quote(file);
}
return args;
}
// Runs a Host command and returns its stdout. Used only for the (small) shell-menu listing.
std::wstring RunHostCapture(const std::wstring& arguments)
{
std::wstring output;
SECURITY_ATTRIBUTES sa = {};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE readPipe = nullptr;
HANDLE writePipe = nullptr;
if (!CreatePipe(&readPipe, &writePipe, &sa, 0))
{
return output;
}
SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
std::wstring commandLine = Quote(FindHostExe()) + L" " + arguments;
STARTUPINFOW si = {};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
si.hStdOutput = writePipe;
si.hStdError = writePipe;
PROCESS_INFORMATION pi = {};
std::vector<wchar_t> mutableCmd(commandLine.begin(), commandLine.end());
mutableCmd.push_back(L'\0');
if (!CreateProcessW(nullptr, mutableCmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
{
CloseHandle(readPipe);
CloseHandle(writePipe);
return output;
}
CloseHandle(writePipe);
char buffer[4096];
DWORD read = 0;
std::string raw;
while (ReadFile(readPipe, buffer, sizeof(buffer), &read, nullptr) && read > 0)
{
raw.append(buffer, read);
}
CloseHandle(readPipe);
WaitForSingleObject(pi.hProcess, 15000);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
if (!raw.empty())
{
const int needed = MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast<int>(raw.size()), nullptr, 0);
if (needed > 0)
{
output.resize(needed);
MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast<int>(raw.size()), output.data(), needed);
}
}
return output;
}
// Runs a Host command fire-and-forget (used to actually execute a script).
void RunHostDetached(const std::wstring& arguments)
{
std::wstring commandLine = Quote(FindHostExe()) + L" " + arguments;
STARTUPINFOW si = {};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi = {};
std::vector<wchar_t> mutableCmd(commandLine.begin(), commandLine.end());
mutableCmd.push_back(L'\0');
if (CreateProcessW(nullptr, mutableCmd.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
{
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
}
struct ScriptEntry
{
std::wstring Id;
std::wstring Name;
};
// Parses "id\tname" lines into entries.
std::vector<ScriptEntry> ParseMenu(const std::wstring& text)
{
std::vector<ScriptEntry> entries;
size_t start = 0;
while (start < text.size())
{
size_t end = text.find(L'\n', start);
std::wstring line = (end == std::wstring::npos) ? text.substr(start) : text.substr(start, end - start);
start = (end == std::wstring::npos) ? text.size() : end + 1;
if (!line.empty() && line.back() == L'\r')
{
line.pop_back();
}
if (line.empty())
{
continue;
}
const size_t tab = line.find(L'\t');
if (tab == std::wstring::npos)
{
continue;
}
ScriptEntry entry;
entry.Id = line.substr(0, tab);
entry.Name = line.substr(tab + 1);
if (!entry.Id.empty())
{
entries.push_back(std::move(entry));
}
}
return entries;
}
}
// A single submenu item: "Convert Markdown to Text", etc.
class PowerScriptSubCommand : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IExplorerCommand>
{
public:
PowerScriptSubCommand(std::wstring id, std::wstring name, std::vector<std::wstring> files) :
m_id(std::move(id)), m_name(std::move(name)), m_files(std::move(files))
{
}
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { return SHStrDupW(m_name.c_str(), name); }
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
IFACEMETHODIMP GetState(IShellItemArray*, BOOL, EXPCMDSTATE* state) override { *state = ECS_ENABLED; return S_OK; }
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_DEFAULT; return S_OK; }
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override { *enumerator = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP Invoke(IShellItemArray* selection, IBindCtx*) override
{
std::vector<std::wstring> files = m_files;
if (files.empty())
{
files = ExtractPaths(selection);
}
RunHostDetached(L"run " + m_id + L" --files" + BuildFilesArguments(files));
return S_OK;
}
private:
std::wstring m_id;
std::wstring m_name;
std::vector<std::wstring> m_files;
};
// IEnumExplorerCommand over the submenu items.
class PowerScriptEnum : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IEnumExplorerCommand>
{
public:
explicit PowerScriptEnum(std::vector<ComPtr<IExplorerCommand>> commands) :
m_commands(std::move(commands))
{
}
IFACEMETHODIMP Next(ULONG count, IExplorerCommand** commands, ULONG* fetched) override
{
ULONG produced = 0;
for (; produced < count && m_index < m_commands.size(); ++produced, ++m_index)
{
m_commands[m_index].CopyTo(&commands[produced]);
}
if (fetched != nullptr)
{
*fetched = produced;
}
return (produced == count) ? S_OK : S_FALSE;
}
IFACEMETHODIMP Skip(ULONG count) override
{
m_index += count;
return (m_index <= m_commands.size()) ? S_OK : S_FALSE;
}
IFACEMETHODIMP Reset() override
{
m_index = 0;
return S_OK;
}
IFACEMETHODIMP Clone(IEnumExplorerCommand** out) override
{
*out = nullptr;
return E_NOTIMPL;
}
private:
std::vector<ComPtr<IExplorerCommand>> m_commands;
size_t m_index = 0;
};
// Top-level "PowerScript" command with a dynamic submenu.
class __declspec(uuid("9FF7C126-9562-4F16-A6FB-9622B26E0D62")) PowerScriptCommand :
public RuntimeClass<RuntimeClassFlags<ClassicCom>, IExplorerCommand, IObjectWithSite>
{
public:
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { return SHStrDupW(L"PowerScript", name); }
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
// Called before EnumSubCommands on the same instance; we use it to compute (and cache) the
// matching scripts and to hide the entry when nothing matches.
IFACEMETHODIMP GetState(IShellItemArray* selection, BOOL, EXPCMDSTATE* state) override
{
m_files = ExtractPaths(selection);
m_entries.clear();
if (!m_files.empty())
{
const std::wstring output = RunHostCapture(L"shell-menu --files" + BuildFilesArguments(m_files));
m_entries = ParseMenu(output);
}
*state = m_entries.empty() ? ECS_HIDDEN : ECS_ENABLED;
return S_OK;
}
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_HASSUBCOMMANDS; return S_OK; }
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override
{
*enumerator = nullptr;
std::vector<ComPtr<IExplorerCommand>> commands;
for (const auto& entry : m_entries)
{
commands.push_back(Make<PowerScriptSubCommand>(entry.Id, entry.Name, m_files));
}
auto enumObject = Make<PowerScriptEnum>(std::move(commands));
return enumObject.CopyTo(enumerator);
}
IFACEMETHODIMP Invoke(IShellItemArray*, IBindCtx*) override { return S_OK; }
// IObjectWithSite
IFACEMETHODIMP SetSite(IUnknown* site) override { m_site = site; return S_OK; }
IFACEMETHODIMP GetSite(REFIID riid, void** ppv) override { return m_site.CopyTo(riid, ppv); }
private:
ComPtr<IUnknown> m_site;
std::vector<std::wstring> m_files;
std::vector<ScriptEntry> m_entries;
};
CoCreatableClass(PowerScriptCommand);
STDAPI DllGetActivationFactory(_In_ HSTRING activatableClassId, _COM_Outptr_ IActivationFactory** factory)
{
return Module<ModuleType::InProc>::GetModule().GetActivationFactory(activatableClassId, factory);
}
STDAPI DllCanUnloadNow()
{
return (Module<InProc>::GetModule().GetObjectCount() == 0 && g_refModule == 0) ? S_OK : S_FALSE;
}
STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _COM_Outptr_ void** ppv)
{
return Module<InProc>::GetModule().GetClassObject(rclsid, riid, ppv);
}
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID)
{
switch (reason)
{
case DLL_PROCESS_ATTACH:
g_hModule = hModule;
DisableThreadLibraryCalls(hModule);
break;
default:
break;
}
return TRUE;
}

View File

@@ -0,0 +1,75 @@
<#
.SYNOPSIS
Builds and registers the PowerScripts Windows 11 modern context-menu handler as an
unsigned sparse (loose-file) MSIX package. Requires Developer Mode.
.DESCRIPTION
1. Builds the native handler DLL (build.cmd).
2. Publishes PowerScripts.Host.exe (framework-dependent) next to the DLL.
3. Copies the manifest + logo assets into a deploy folder.
4. Registers the package in place via Add-AppxPackage -Register.
Run register.ps1 -Unregister to remove it.
#>
[CmdletBinding()]
param(
[switch]$Unregister,
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Debug'
)
$ErrorActionPreference = 'Stop'
$PackageName = 'Microsoft.PowerToys.PowerScriptsContextMenu'
$here = Split-Path -Parent $MyInvocation.MyCommand.Definition
$deployDir = Join-Path $env:LOCALAPPDATA 'Microsoft\PowerToys\PowerScriptsContextMenu'
if ($Unregister)
{
$pkg = Get-AppxPackage -Name $PackageName -ErrorAction SilentlyContinue
if ($pkg)
{
Remove-AppxPackage -Package $pkg.PackageFullName
Write-Host "Unregistered $($pkg.PackageFullName)"
}
else
{
Write-Host "Package $PackageName is not registered."
}
return
}
Write-Host '== Building handler DLL =='
& cmd /c "`"$here\build.cmd`""
if ($LASTEXITCODE -ne 0) { throw 'DLL build failed.' }
Write-Host '== Publishing PowerScripts.Host =='
$hostProj = Join-Path $here '..\PowerScripts.Host\PowerScripts.Host.csproj'
$hostPublish = Join-Path $here 'hostpublish'
& dotnet publish $hostProj -c $Configuration -o $hostPublish --nologo | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Host publish failed.' }
Write-Host '== Staging deploy folder =='
# Re-register cleanly: remove any prior registration before overwriting files.
$existing = Get-AppxPackage -Name $PackageName -ErrorAction SilentlyContinue
if ($existing) { Remove-AppxPackage -Package $existing.PackageFullName }
if (Test-Path $deployDir) { Remove-Item $deployDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $deployDir | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $deployDir 'Assets') | Out-Null
Copy-Item (Join-Path $here 'PowerToys.PowerScriptsContextMenu.dll') $deployDir -Force
Copy-Item (Join-Path $here 'AppxManifest.xml') $deployDir -Force
Copy-Item (Join-Path $hostPublish '*') $deployDir -Recurse -Force
# Reuse the ImageResizer context-menu logo assets for the required tile slots.
$assetSrc = Join-Path $here '..\..\..\modules\imageresizer\ImageResizerContextMenu\Assets\ImageResizer'
foreach ($asset in 'storelogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png', 'Wide310x150Logo.png', 'LargeTile.png', 'SmallTile.png', 'SplashScreen.png')
{
Copy-Item (Join-Path $assetSrc $asset) (Join-Path $deployDir 'Assets') -Force
}
Write-Host '== Registering package =='
Add-AppxPackage -Register (Join-Path $deployDir 'AppxManifest.xml')
Write-Host "Registered. Deploy folder: $deployDir"
Write-Host 'Right-click a matching file (e.g. a .md) to see the PowerScript submenu (restart Explorer if needed).'

View File

@@ -0,0 +1,165 @@
# PowerScripts (prototype)
> **Status: prototype.** Write a small script once and surface it across PowerToys.
> This folder contains the **working core** (manifest schema, registry, shared executor
> `PowerScripts.Host.exe`) plus sample scripts, and three **implemented surfaces**:
> a Settings module page, the Explorer right-click menu, and the Keyboard Manager editor.
## Implemented surfaces (prototype)
| Surface | What it does | How |
| --- | --- | --- |
| **Settings module** | New "PowerScripts" page in the Settings app that lists installed scripts and has an enable toggle. Enabling/disabling installs/removes the Explorer context-menu entries. | `src/settings-ui/.../Views/PowerScriptsPage.xaml(.cs)` + `PowerScriptsViewModel`; reads `Host.exe list --json`; toggle runs `Host.exe shell-install`/`shell-uninstall`. |
| **Explorer right-click** | Right-click a file → "PowerScript" submenu lists scripts whose manifest declares that extension; clicking runs the script on the file. | `Host.exe shell-install` writes `HKCU\Software\Classes\SystemFileAssociations\<ext>\shell\PowerScripts` cascading verbs → `Host.exe run <id> --files "%1"`. |
| **Keyboard Manager** | A new "PowerScript" action in the KBM editor; pick a system script and assign it to a hotkey. | `KeyboardManagerEditorUI` action picker saves an ordinary `RunProgram` mapping → `Host.exe run <id>`. |
### End-to-end demo
1. **Settings**: open Settings → PowerScripts → see `convert_md_to_txt`, `volume_up`, etc.; toggle on.
2. **Context menu**: right-click a `.md` file → PowerScript → "Convert Markdown to Text" → a `.txt` is written next to it.
3. **Keyboard Manager**: KBM editor → add mapping → action "PowerScript" → pick "Volume Up" → assign a shortcut.
## The idea
A **PowerScript** is a script plus a manifest, living in its own folder. Two flavours:
- **System** (`kind: "system"`) — "do something on my PC". No file input. Triggered by a Keyboard
Manager hotkey (and later the Command Palette).
- **File** (`kind: "file"`) — "do something with this file". Input is one or more files of declared
types. Surfaced in the Explorer right-click menu.
Every surface is a thin consumer of one **registry** and invokes one **executor** — so a script is
authored once and appears everywhere it's declared.
## Architecture
```
Registry (PowerScripts.Core) ──read──► surfaces:
scans <root>/<id>/manifest.json • Explorer context menu (file actions)
• Keyboard Manager editor (system actions)
• Command Palette / Advanced Paste (later)
▲ │ invoke
└──────────── all surfaces ────────────────┘
PowerScripts.Host.exe (executor)
list [--json] | run <id> [--files ...] [--set k=v ...]
```
- **`PowerScripts.Core`** — manifest model + JSON (`Manifest/`), validation, registry (`Registry/`),
executor (`Execution/`).
- **`PowerScripts.Host`** — the CLI every surface points at. `list --json` is the structured catalogue
the KBM editor picker and future agents/MCP consume; `run <id>` executes.
- **`samples/`** — `system-snapshot` & `volume_up` (system), `sha256-checksum` & `convert_md_to_txt` (file).
### Scripts root
`%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts\scripts\<id>\manifest.json`
(override with the `POWERSCRIPTS_ROOT` env var or `--root`).
## Manifest schema (v1)
```jsonc
{
"schemaVersion": 1,
"id": "heic-to-jpg", // must match the folder name
"name": "Convert HEIC to JPG",
"description": "…",
"kind": "file", // "system" | "file"
"runtime": "powershell", // prototype: powershell only
"entry": "run.ps1",
"input": { "extensions": [".heic"], "minFiles": 1, "maxFiles": 0 }, // file kind
"output": { "type": "convertedFile", "extension": ".jpg" },
"parameters": [ { "name": "quality", "type": "int", "default": "90", "min": 1, "max": 100 } ],
"surfaces": ["contextMenu", "keyboardManager"],
"capabilities": ["fileWrite"], // consent string + agent permission contract
"elevation": "asInvoker" // prototype always runs non-elevated
}
```
## Build & run
```powershell
cd src\modules\PowerScripts
dotnet build PowerScripts.Host\PowerScripts.Host.csproj -c Debug
$env:POWERSCRIPTS_ROOT = "$PWD\samples"
$exe = "PowerScripts.Host\bin\Debug\net10.0\PowerScripts.Host.exe"
& $exe list
& $exe run system-snapshot
& $exe run sha256-checksum --files C:\some\file.png
```
> The prototype projects are isolated from the repo build via local `Directory.Build.props`,
> `Directory.Packages.props` and `nuget.config` (no StyleCop / warnings-as-errors / central package
> management; restores from public nuget.org). Delete these three files when promoting the module to
> follow standard PowerToys build rules.
## Tests
```powershell
cd src\modules\PowerScripts
dotnet test PowerScripts.Core.Tests\PowerScripts.Core.Tests.csproj
```
`PowerScripts.Core.Tests` (MSTest) covers manifest serialization/validation and the registry
(extension + wildcard matching, multi-file selection min/max, kind filtering, invalid-script
skipping). 9 tests, all passing.
## Surface integration plans
### 1. Keyboard Manager (system actions) — first priority
KBM already has a `RunProgram` action, so a hotkey → PowerScript works **today**. Get the exact
mapping for a system script:
```powershell
& $exe kbm system-snapshot # prints Program path + Arguments for the editor
& $exe kbm system-snapshot --json # prints the raw remapShortcutsToRunProgram object
```
Then in Keyboard Manager → *Remap a shortcut* → action **Run Program**, paste the Program path and
`run <id>` arguments and choose the trigger keys. The mapping persists as the existing engine shape
(verified against `common/KeyboardManagerConstants.h`):
```json
{ "operationType": 1, "runProgramFilePath": "…\\PowerScripts.Host.exe", "runProgramArgs": "run system-snapshot", "unicodeText": "*Unsupported*" }
```
**Prototype goal — pick a PowerScript inside the editor** (instead of typing a path). The editor is
**C# WinUI 3** (`PowerToys.KeyboardManagerEditorUI.exe`), a separate process that already reads JSON
at runtime, so it can call `Host.exe list --json` to populate a script dropdown. Additive change-list
(verified against the current source):
- `Controls/UnifiedMappingControl.xaml.cs` — the nested `enum ActionType` (KeyOrShortcut, Text,
OpenUrl, OpenApp, MouseClick, Disable): add a `PowerScript` value; extend `CurrentActionType`,
`SetActionType`, `IsInputComplete`.
- `Controls/UnifiedMappingControl.xaml` — add a `ComboBoxItem` (Tag `PowerScript`) to
`ActionTypeComboBox` and a `SwitchPresenter` `Case` hosting a script-picker ComboBox.
- `Pages/MainPage.xaml.cs` — add a `UnifiedMappingControl.ActionType.PowerScript` arm to the save
`switch` (~line 390) that reuses the `SaveProgramMapping` path with
`ProgramPath = <PowerScripts.Host.exe>` and `ProgramArgs = "run <id>"`.
- A small helper in `KeyboardManagerEditorUI` to load the script list (shell out to `Host.exe
list --json`, like `Settings/SettingsManager.cs` reads its JSON).
- **No KBM engine change** — it stays a `RunProgram` mapping.
> The editor-picker edits live in the shared KBM WinUI project, which needs the full PowerToys build
> (VS + internal NuGet feeds) to compile — do them in that environment. The `kbm` command above is
> the verifiable, build-free path that already delivers hotkey → PowerScript.
### 2. Explorer right-click (file actions)
A single compiled `IExplorerCommand` COM handler (pattern: `src/modules/NewPlus/NewShellExtensionContextMenu`)
reads the registry, filters `kind:"file"` scripts whose `input.extensions` match the selection, and
shows a dynamic submenu. Invoking an item runs `Host.exe run <id> --files <paths>`.
### Deferred (kept easy by the registry design)
Command Palette (one `ICommandProvider` extension enumerating system scripts) and Advanced Paste —
both become additional registry-reading adapters. No core changes expected.
## Agent / AI tie-in (designed-for)
`Host.exe list --json` already yields a structured, permissioned capability list and `run <id>` is
the invoke — so an MCP server can expose installed PowerScripts as user-consented tools. AI authoring
("generate a PowerScript that…") emits a manifest + script folder the user reviews once.

View File

@@ -0,0 +1,97 @@
<#
.SYNOPSIS
End-to-end test helper for invoking a PowerScript from Keyboard Manager (new editor).
.DESCRIPTION
Self-contained KBM e2e that doesn't require the full PowerToys runner:
1. Forces the *new* Keyboard Manager editor (useNewEditor = true).
2. Launches PowerToys.KeyboardManagerEditorUI.exe so you can add a shortcut whose
action is "PowerScript" -> pick a system script (e.g. "Volume Up") -> Save.
3. Starts PowerToys.KeyboardManagerEngine.exe standalone, which reads the saved
default.json and installs the keyboard hook. Press your shortcut and the engine
runs PowerScripts.Host.exe run <id>.
Defaults assume a Debug build under <repo>\x64\Debug. Use -Configuration Release for a
release layout.
.EXAMPLE
# Configure a hotkey, then start the engine and test:
pwsh -File kbm-e2e.ps1
.EXAMPLE
# Skip the editor; just (re)start the engine to apply the current mappings:
pwsh -File kbm-e2e.ps1 -EngineOnly
#>
[CmdletBinding()]
param(
[switch]$EngineOnly,
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Debug'
)
$ErrorActionPreference = 'Stop'
# Repo root = four levels up from src\modules\PowerScripts.
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
$binRoot = Join-Path $repoRoot "x64\$Configuration"
$editorExe = Join-Path $binRoot 'WinUI3Apps\PowerToys.KeyboardManagerEditorUI.exe'
$engineExe = Join-Path $binRoot 'KeyboardManagerEngine\PowerToys.KeyboardManagerEngine.exe'
$kbmDir = Join-Path $env:LOCALAPPDATA 'Microsoft\PowerToys\Keyboard Manager'
$settings = Join-Path $kbmDir 'settings.json'
function Stop-ProcessesByName([string[]]$names)
{
$ids = Get-Process -ErrorAction SilentlyContinue | Where-Object { $names -contains $_.Name } | Select-Object -ExpandProperty Id
foreach ($id in $ids) { try { Stop-Process -Id $id -Force } catch { } }
}
if (-not (Test-Path $engineExe)) { throw "Engine not found: $engineExe. Build KeyboardManagerEngine first." }
# 1. Force the new editor.
if (Test-Path $settings)
{
$json = Get-Content $settings -Raw | ConvertFrom-Json
if ($json.properties.PSObject.Properties.Name -contains 'useNewEditor')
{
$json.properties.useNewEditor = $true
}
($json | ConvertTo-Json -Depth 10) | Set-Content $settings -Encoding UTF8
Write-Host 'Set useNewEditor = true.'
}
# 2. Launch the new editor (unless engine-only) and wait for the user to finish.
if (-not $EngineOnly)
{
if (-not (Test-Path $editorExe)) { throw "Editor not found: $editorExe. Build KeyboardManagerEditorUI first." }
Write-Host ''
Write-Host 'Opening the NEW Keyboard Manager editor.' -ForegroundColor Cyan
Write-Host ' - Click "Add shortcut", set a trigger (e.g. Ctrl+Alt+U).'
Write-Host ' - Action type -> PowerScript -> pick a System script (e.g. Volume Up).'
Write-Host ' - Save, then CLOSE the editor window to continue.'
Write-Host ''
# Pass this process id as the parent so the editor stays open until you close it.
$editor = Start-Process -FilePath $editorExe -ArgumentList "$PID" -PassThru
$editor.WaitForExit()
Write-Host 'Editor closed.'
}
# 3. (Re)start the engine standalone so it applies the saved mappings.
Stop-ProcessesByName @('PowerToys.KeyboardManagerEngine')
Start-Sleep -Milliseconds 500
$engine = Start-Process -FilePath $engineExe -PassThru
Start-Sleep -Seconds 1
if (Get-Process -Id $engine.Id -ErrorAction SilentlyContinue)
{
Write-Host ''
Write-Host "KBM engine running (pid $($engine.Id))." -ForegroundColor Green
Write-Host 'Press your configured shortcut now — the PowerScript should run.'
Write-Host "Stop the engine when done: Stop-Process -Id $($engine.Id)"
}
else
{
throw 'Engine exited immediately. Check the KBM logs under the Keyboard Manager\Logs folder.'
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
PROTOTYPE-ONLY: restore the isolated PowerScripts prototype projects from public nuget.org instead
of the repo's auth-gated internal feed. Remove when promoting the module to the standard build.
-->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

View File

@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "convert_md_to_txt",
"name": "Convert Markdown to Text",
"description": "Convert the selected Markdown file(s) to a plain .txt file next to the original.",
"kind": "file",
"runtime": "powershell",
"entry": "run.ps1",
"input": {
"extensions": [".md"],
"minFiles": 1,
"maxFiles": 0
},
"output": {
"type": "convertedFile",
"extension": ".txt"
},
"surfaces": ["contextMenu"],
"capabilities": ["fileRead", "fileWrite"],
"elevation": "asInvoker"
}

View File

@@ -0,0 +1,35 @@
# Convert Markdown to Text — a "file" PowerScript surfaced on .md right-click.
# Writes a plain .txt next to each selected .md file (light Markdown stripping).
param(
[string[]]$Files
)
if (-not $Files -or $Files.Count -eq 0) {
if ($env:POWERSCRIPTS_FILES) {
$Files = $env:POWERSCRIPTS_FILES -split "`n"
}
}
if (-not $Files -or $Files.Count -eq 0) {
Write-Error 'No files provided.'
exit 1
}
foreach ($f in $Files) {
$path = $f.Trim()
if (-not $path) { continue }
if (-not (Test-Path -LiteralPath $path)) {
Write-Warning "Not found: $path"
continue
}
$text = Get-Content -LiteralPath $path -Raw
# Light Markdown stripping: headings, emphasis markers, inline code backticks.
$text = $text -replace '(?m)^\s{0,3}#{1,6}\s*', ''
$text = $text -replace '(\*\*|__|\*|_|`)', ''
$out = [System.IO.Path]::ChangeExtension($path, '.txt')
Set-Content -LiteralPath $out -Value $text -Encoding UTF8
"Converted: $out"
}

View File

@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "sha256-checksum",
"name": "Compute SHA-256",
"description": "Compute the SHA-256 checksum of the selected file(s).",
"kind": "file",
"runtime": "powershell",
"entry": "run.ps1",
"input": {
"extensions": ["*"],
"minFiles": 1,
"maxFiles": 0
},
"output": {
"type": "sideEffect"
},
"surfaces": ["contextMenu"],
"capabilities": ["fileRead"],
"elevation": "asInvoker"
}

View File

@@ -0,0 +1,30 @@
# Compute SHA-256 — a "file" PowerScript.
# Surfaced in the Explorer right-click menu for the selected file(s).
# Files arrive both as -Files and via the POWERSCRIPTS_FILES environment variable.
param(
[string[]]$Files
)
if (-not $Files -or $Files.Count -eq 0) {
if ($env:POWERSCRIPTS_FILES) {
$Files = $env:POWERSCRIPTS_FILES -split "`n"
}
}
if (-not $Files -or $Files.Count -eq 0) {
Write-Error 'No files provided.'
exit 1
}
foreach ($f in $Files) {
$path = $f.Trim()
if (-not $path) { continue }
if (-not (Test-Path -LiteralPath $path)) {
Write-Warning "Not found: $path"
continue
}
$hash = Get-FileHash -LiteralPath $path -Algorithm SHA256
'{0} {1}' -f $hash.Hash, $path
}

View File

@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"id": "system-snapshot",
"name": "System Snapshot",
"description": "Show computer name, OS and uptime.",
"kind": "system",
"runtime": "powershell",
"entry": "run.ps1",
"surfaces": ["keyboardManager", "commandPalette"],
"capabilities": ["systemInfo"],
"elevation": "asInvoker"
}

View File

@@ -0,0 +1,12 @@
# System Snapshot — a "system" PowerScript (no file input).
# Surfaced via a Keyboard Manager hotkey or the Command Palette.
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
[pscustomobject]@{
Computer = $env:COMPUTERNAME
User = $env:USERNAME
OS = if ($os) { $os.Caption } else { [System.Environment]::OSVersion.VersionString }
Uptime = if ($os) { (Get-Date) - $os.LastBootUpTime } else { 'n/a' }
Time = (Get-Date).ToString('s')
} | Format-List

View File

@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"id": "volume_up",
"name": "Volume Up",
"description": "Raise the system volume a few steps.",
"kind": "system",
"runtime": "powershell",
"entry": "run.ps1",
"surfaces": ["keyboardManager", "commandPalette"],
"capabilities": ["systemControl"],
"elevation": "asInvoker"
}

View File

@@ -0,0 +1,11 @@
# Volume Up — a "system" PowerScript (no file input).
# Assign it to a hotkey in Keyboard Manager. Sends the system "Volume Up" media key a few times.
$wsh = New-Object -ComObject WScript.Shell
for ($i = 0; $i -lt 4; $i++) {
# 0xAF (175) is the Volume Up virtual key.
$wsh.SendKeys([char]175)
Start-Sleep -Milliseconds 40
}
'Volume raised.'

View File

@@ -1,821 +0,0 @@
PackageName: BlackmagicDesign.DaVinciResolve
Name: DaVinci Resolve
WindowFilter: "Resolve.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Popular shortcuts
Properties:
- Name: Edit
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F5
- Name: Color
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F6
- Name: Fairlight
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F7
- Name: Deliver
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F8
- Name: Play / Pause
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Space
- Name: Play Reverse
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- J
- Name: Stop
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Play Forward
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- L
- Name: Import Media
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- I
- Name: Export / Deliver
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- E
- Name: Save Project
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- S
- Name: Cut Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Blade Edit
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Backslash
- Name: Ripple Delete
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Delete
- Name: Undo
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Z
- Name: Redo
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Z
- Name: Mark In
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- I
- Name: Mark Out
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- O
- Name: Marker
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- M
- Name: Select All
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- A
- Name: Go to Beginning
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Home
- Name: Go to End
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- End
- Name: Snapping
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- N
- Name: Selection Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- A
- Name: Trim Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- T
- Name: Change Clip Speed
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- R
- SectionName: Timeline navigation
Properties:
- Name: Go to Next Frame
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Right
- Name: Go to Previous Frame
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Left
- Name: Jump Forward 5 Frames
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Right
- Name: Jump Back 5 Frames
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Left
- Name: Go to Next Clip
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Up
- Name: Go to Previous Clip
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Down
- Name: Go to Next Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Down
- Name: Go to Previous Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Up
- Name: Zoom In Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Equals
- Name: Zoom Out Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Minus
- Name: Full Screen Playback
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Space
- Name: Go to Previous Edit Point
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageUp
- Name: Go to Next Edit Point
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageDown
- SectionName: Edit
Properties:
- Name: Delete
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Delete
- Name: Copy
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- C
- Name: Paste
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- V
- Name: Cut
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- X
- Name: Duplicate Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Render in Place
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- X
- Name: Add Edit
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Backslash
- Name: Append to End of Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- End
- Name: Replace Clip
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- R
- Name: Move Clip Up One Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Up
- Name: Move Clip Down One Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Down
- Name: Split Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Link Clips
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- L
- Name: Create Compound Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- G
- SectionName: Color
Properties:
- Name: Add Serial Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- S
- Name: Add Parallel Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- P
- Name: Add Layer Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- L
- Name: Select Node 1
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: Select Node 2
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: Select Node 3
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: Select Node 4
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Select Node 5
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "5"
- Name: Enable/Disable Current Grade
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- D
- Name: Preview Mode
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- W
- Name: Grade All Frames in Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Keyframe Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Select Color Wheels
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: Select Curves
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: Select Qualifier
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: Select Power Window
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Select Tracking
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "5"
- Name: Reset Color Grade
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- U
- SectionName: Fairlight
Properties:
- Name: Mute Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- M
- Name: Solo Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- S
- Name: Automation Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F
- Name: Record Arm Selected Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- R
- Name: Headphones Solo
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- H
- Name: Add Marker
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Insert
- Name: Add Audio Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- A
- Name: Bounce Mix
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- X
- SectionName: Fusion
Properties:
- Name: Switch Between Spline and Keyframes
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Add Keyframe
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Shift
- Name: View Current Tool
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: View Node Flow
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: View Keyframes
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: View Spline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Merge Selected Tools
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Bypass Selected Tool
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- SectionName: Media
Properties:
- Name: Reveal in Explorer
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Smart Bin
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- S
- Name: Rename Clip
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F2
- Name: Import XML / AAF
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- I
- Name: Create New Bin
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Add Clip to Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Enter
- Name: Viewer Zoom In
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Equals
- Name: Viewer Zoom Out
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Minus
- SectionName: Deliver
Properties:
- Name: Add to Render Queue
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Enter
- Name: Start Render
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Enter
- Name: Select Preset
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F2
- Name: Render Settings
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Browse Output Location
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B

View File

@@ -443,7 +443,7 @@ public sealed partial class MainListPage : DynamicListPage,
{
specialFallbacks.Add(s);
}
else if (s.IsEnabled)
else
{
commonFallbacks.Add(s);
}

View File

@@ -105,13 +105,6 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public string? Homepage => _entry.Homepage;
// Validated, browser-openable homepage uri. Null when the entry has no
// homepage or it is not a web uri. NavigateUri bindings must use this
// (a Uri) rather than the raw Homepage string: x:Bind evaluates bindings
// regardless of element visibility, and converting a null/invalid string
// to Uri throws and crashes the page.
public Uri? HomepageUri => _homepageHttpUri;
public Uri IconUri { get; }
public ImageSource IconSource

View File

@@ -156,9 +156,8 @@ public sealed partial class CommandBar : UserControl,
private void ContextMenuFlyout_Opened(object sender, object e)
{
// Focus the filter box so the flyout captures keyboard input,
// then fire a single consolidated Narrator announcement.
// We need to wait until our flyout is opened to try and toss focus
// at its search box. The control isn't in the UI tree before that
ContextControl.FocusSearchBox();
ContextControl.AnnounceOpened();
}
}

View File

@@ -139,23 +139,10 @@
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<!--
Hidden element used solely for raising Narrator notifications.
It must be Content-visible in UIA but has no visual presence.
-->
<TextBlock
x:Name="NarratorAnnouncer"
Width="0"
Height="0"
AutomationProperties.AccessibilityView="Content"
AutomationProperties.LiveSetting="Assertive" />
<ListView
x:Name="CommandsDropdown"
MinWidth="248"
Margin="0,4,0,2"
AutomationProperties.AccessibilityView="Raw"
IsItemClickEnabled="True"
ItemClick="CommandsDropdown_ItemClick"
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
@@ -181,7 +168,6 @@
x:Uid="ContextFilterBox"
Margin="0"
Padding="10,7,6,8"
AutomationProperties.AccessibilityView="Raw"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
BorderThickness="0,0,0,2"
CornerRadius="8, 8, 0, 0"

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Common.Text;
@@ -13,7 +11,6 @@ using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Windows.System;
@@ -30,15 +27,6 @@ public sealed partial class ContextMenu : UserControl,
public static readonly DependencyProperty SubscribeToCommandBarProperty =
DependencyProperty.Register(nameof(SubscribeToCommandBar), typeof(bool), typeof(ContextMenu), new PropertyMetadata(true, OnSubscribeToCommandBarChanged));
private static readonly CompositeFormat _contextMenuOpenedFormat =
CompositeFormat.Parse(ResourceLoaderInstance.GetString("ScreenReader_Announcement_ContextMenuOpened"));
/// <summary>
/// True while the context menu is transitioning from PrepareForOpen to AnnounceOpened.
/// Prevents ViewModel_PropertyChanged from triggering UIA-visible selection changes.
/// </summary>
private bool _isOpening;
public bool ShowFilterBox
{
get => (bool)GetValue(ShowFilterBoxProperty);
@@ -115,47 +103,12 @@ public sealed partial class ContextMenu : UserControl,
internal void PrepareForOpen(ContextMenuFilterLocation filterLocation)
{
_isOpening = true;
ViewModel.FilterOnTop = filterLocation == ContextMenuFilterLocation.Top;
ViewModel.ResetContextMenu();
UpdateUiForStackChange();
}
/// <summary>
/// Fires a single consolidated Narrator announcement.
/// Call this after the flyout is opened and focus has been set.
/// </summary>
internal void AnnounceOpened()
{
// Defer the announcement to the next dispatcher cycle. This ensures
// any pending FilteredItems updates have completed and the flyout
// content is fully materialized in the UIA tree.
DispatcherQueue.TryEnqueue(() =>
{
_isOpening = false;
var commandItems = ViewModel.FilteredItems.OfType<CommandContextItemViewModel>().ToList();
var itemCount = commandItems.Count;
var selectedItem = CommandsDropdown.SelectedItem as CommandContextItemViewModel;
var selectedName = selectedItem?.Title ?? string.Empty;
var selectedIndex = selectedItem is not null ? commandItems.IndexOf(selectedItem) + 1 : 0;
var announcement = string.Format(
CultureInfo.CurrentCulture,
_contextMenuOpenedFormat,
itemCount,
selectedName,
selectedIndex);
RaiseNarratorNotification(
AutomationNotificationKind.ActionCompleted,
announcement,
"ContextMenuOpened");
});
}
public void Receive(UpdateCommandBarMessage message)
{
UpdateUiForStackChange();
@@ -244,7 +197,7 @@ public sealed partial class ContextMenu : UserControl,
{
var prop = e.PropertyName;
if (prop == nameof(ContextMenuViewModel.FilteredItems) && !_isOpening)
if (prop == nameof(ContextMenuViewModel.FilteredItems))
{
UpdateUiForStackChange();
}
@@ -302,14 +255,12 @@ public sealed partial class ContextMenu : UserControl,
if (e.Key == VirtualKey.Up)
{
NavigateUp();
AnnounceSelectedItem();
e.Handled = true;
}
else if (e.Key == VirtualKey.Down)
{
NavigateDown();
AnnounceSelectedItem();
e.Handled = true;
}
@@ -396,46 +347,6 @@ public sealed partial class ContextMenu : UserControl,
return item is SeparatorViewModel;
}
private void AnnounceSelectedItem()
{
if (CommandsDropdown.SelectedItem is not CommandContextItemViewModel selected)
{
return;
}
var commandItems = ViewModel.FilteredItems.OfType<CommandContextItemViewModel>().ToList();
var position = commandItems.IndexOf(selected) + 1;
var total = commandItems.Count;
var announcement = $"{selected.Title}, {position} of {total}";
RaiseNarratorNotification(
AutomationNotificationKind.ItemAdded,
announcement,
"ContextMenuSelectionChanged");
}
/// <summary>
/// Raises a UIA notification via the dedicated NarratorAnnouncer element.
/// Ensures the element has a peer (forcing layout if needed on first use).
/// </summary>
private void RaiseNarratorNotification(AutomationNotificationKind kind, string announcement, string activityId)
{
// On first flyout open the announcer may not have a peer yet.
// UpdateLayout ensures the element is materialized in the UIA tree.
var peer = FrameworkElementAutomationPeer.FromElement(NarratorAnnouncer);
if (peer is null)
{
NarratorAnnouncer.UpdateLayout();
peer = FrameworkElementAutomationPeer.CreatePeerForElement(NarratorAnnouncer);
}
peer?.RaiseNotificationEvent(
kind,
AutomationNotificationProcessing.ImportantMostRecent,
announcement,
activityId);
}
private void UpdateUiForStackChange()
{
ContextFilterBox.Text = string.Empty;

View File

@@ -500,10 +500,9 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void ContextMenuFlyout_Opened(object sender, object e)
{
// Focus the filter box so the flyout captures keyboard input,
// then fire a single consolidated Narrator announcement.
// We need to wait until our flyout is opened to try and toss focus
// at its search box. The control isn't in the UI tree before that
ContextControl.FocusSearchBox();
ContextControl.AnnounceOpened();
}
public void Receive(CloseContextMenuMessage message)

View File

@@ -232,7 +232,7 @@
Grid.Row="3"
Padding="0"
AutomationProperties.AutomationId="CmdPal_GalleryItemPage_ViewRepository"
NavigateUri="{x:Bind ViewModel.HomepageUri, Mode=OneWay}"
NavigateUri="{x:Bind ViewModel.Homepage, Mode=OneWay}"
ToolTipService.ToolTip="{x:Bind ViewModel.Homepage, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasHomepage), Mode=OneWay, FallbackValue=Collapsed}">
<StackPanel Orientation="Horizontal" Spacing="4">
@@ -332,7 +332,7 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="232" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
@@ -365,7 +365,6 @@
<SolidColorBrush x:Key="ItemContainerBackgroundPressed" Color="Transparent" />
</ItemContainer.Resources>
<Border
Width="356"
Height="200"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"

View File

@@ -160,24 +160,20 @@ public sealed partial class SettingsWindow : WindowEx,
break;
}
if (pageType is null)
if (pageType is not null)
{
return;
}
NavFrame.Navigate(pageType);
if (NavFrame.Content?.GetType() == pageType)
{
return;
}
NavFrame.Navigate(pageType);
// Now, make sure to actually select the correct menu item too
foreach (var obj in NavView.MenuItems)
{
if (obj is NavigationViewItem item && item.Tag is string s && s == page)
// Now, make sure to actually select the correct menu item too
foreach (var obj in NavView.MenuItems)
{
NavView.SelectedItem = item;
if (obj is NavigationViewItem item)
{
if (item.Tag is string s && s == page)
{
NavView.SelectedItem = item;
}
}
}
}
}

View File

@@ -559,9 +559,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="ScreenReader_Announcement_NavigatedToPage0" xml:space="preserve">
<value>Navigated to {0} page</value>
</data>
<data name="ScreenReader_Announcement_ContextMenuOpened" xml:space="preserve">
<value>Menu, {0} commands. {1}, {2} of {0}.</value>
</data>
<data name="SettingsButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Settings (Ctrl+,)</value>
</data>

View File

@@ -4,23 +4,13 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="using:Microsoft.PowerToys.Common.UI.Controls.Window"
xmlns:controls="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<controls:TransientSurface
x:Name="Surface"
MaxWidth="560"
Margin="24,24,24,16"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
HideTransition="Bottom"
ShowTransition="Bottom">
<TextBlock
Margin="16,10,20,12"
Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</controls:TransientSurface>
<TextBlock
Margin="16,10,20,12"
Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</common:TransparentWindow>

View File

@@ -17,14 +17,11 @@ using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// CmdPal's transient toast notification. It is a bare
/// <see cref="TransparentWindow"/> host whose content is a
/// <see cref="Microsoft.PowerToys.Common.UI.Controls.TransientSurface"/> — the
/// surface supplies the acrylic, border, corners, shadow, and the fade/slide
/// animation, driven automatically off the window's show/hide events. This class
/// adds only the bits bespoke to CmdPal toasts: a bound message <c>TextBlock</c>,
/// a 2.5 s auto-dismiss timer, bottom-center positioning, and
/// <see cref="QuitMessage"/> handling.
/// CmdPal's transient toast banner. Inherits all of its chrome, click-through,
/// acrylic, and fade/slide animations from
/// <see cref="TransparentWindow"/>; adds only the bits that are bespoke to
/// CmdPal toasts: a bound message <c>TextBlock</c>, a 2.5 s auto-dismiss timer,
/// bottom-center positioning, and <see cref="QuitMessage"/> handling.
/// </summary>
public sealed partial class ToastWindow : TransparentWindow,
IRecipient<QuitMessage>
@@ -42,10 +39,13 @@ public sealed partial class ToastWindow : TransparentWindow,
AppWindow.Title = RS_.GetString("ToastWindowTitle");
this.SetWindowSize(600, 180);
// Let the surface animate itself in/out in response to this window's
// Show()/Hide(). The 600x180 window leaves the bottom-center 560-wide
// pill (positioned in XAML) room for its slide + shadow.
Surface.SubscribeTo(this);
// Pin the chrome card to bottom-center with the toast's classic 560-wide
// pill shape. The window itself stays 600x180 so the slide animations
// have headroom and we don't have to chase SizeToContent.
Card.HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center;
Card.VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom;
Card.MaxWidth = 560;
Card.Margin = new Microsoft.UI.Xaml.Thickness(24, 24, 24, 16);
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
}

View File

@@ -119,7 +119,6 @@ public class ExtensionGalleryItemViewModelTests
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.HasHomepage);
Assert.IsNull(viewModel.HomepageUri);
Assert.IsFalse(viewModel.HasAuthorUrl);
Assert.IsFalse(viewModel.HasUrlSource);
Assert.IsFalse(viewModel.HasActionableSourceDetails);
@@ -132,32 +131,6 @@ public class ExtensionGalleryItemViewModelTests
Assert.IsFalse(viewModel.OpenInstallUrlCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_SetsHomepageUri_WhenHomepageIsWebUri()
{
var entry = CreateEntry(iconUrl: null);
entry.Homepage = "https://example.com/extension";
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.HasHomepage);
Assert.AreEqual(new Uri("https://example.com/extension"), viewModel.HomepageUri);
Assert.IsTrue(viewModel.OpenHomepageCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_LeavesHomepageUriNull_WhenHomepageIsMissing()
{
var entry = CreateEntry(iconUrl: null);
entry.Homepage = null;
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.HasHomepage);
Assert.IsNull(viewModel.HomepageUri);
Assert.IsFalse(viewModel.OpenHomepageCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_EnablesCopyCommand_WhenWinGetIdIsAvailable()
{

View File

@@ -134,25 +134,6 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
MoreCommands = _networkPage.Commands,
};
if (isBandPage)
{
_networkUpItem = new ListItem(_networkPage)
{
Title = $"{_networkUpSpeed}",
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
Icon = Icons.NetworkUpIcon,
MoreCommands = _networkPage.Commands,
};
_networkDownItem = new ListItem(_networkPage)
{
Title = $"{_networkDownSpeed}",
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
Icon = Icons.NetworkDownIcon,
MoreCommands = _networkPage.Commands,
};
}
_networkPage.Updated += (s, e) =>
{
_networkItem.Title = _networkPage.GetItemTitle(isBandPage);
@@ -272,6 +253,22 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
}
else
{
_networkUpItem = new ListItem(_networkPage!)
{
Title = $"{_networkUpSpeed}",
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
Icon = Icons.NetworkUpIcon,
MoreCommands = _networkPage!.Commands,
};
_networkDownItem = new ListItem(_networkPage!)
{
Title = $"{_networkDownSpeed}",
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
Icon = Icons.NetworkDownIcon,
MoreCommands = _networkPage!.Commands,
};
return _batteryItem is not null
? new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem!, _batteryItem! }
: new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem! };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -59,6 +59,7 @@
</PropertyGroup>
<!-- Props that are constant for both Debug and Release configurations -->
<PropertyGroup Label="Configuration">
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
<CharacterSet>Unicode</CharacterSet>
<SpectreMitigation>Spectre</SpectreMitigation>
@@ -163,7 +164,7 @@
<Import Project="..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets" Condition="Exists('..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets" Condition="Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" />
</ImportGroup>
<Import Project="..\..\..\..\deps\spdlog.props" />
<Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild">
@@ -180,7 +181,7 @@
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" />
</Target>
<Target Name="FakeResourcesPriMerge" BeforeTargets="FinalizeBuildStatus" DependsOnTargets="CopyFilesToOutputDirectory">
<Message Text="Renaming Microsoft.UI.Xaml.pri to resources.pri" />

View File

@@ -3,6 +3,6 @@
<package id="Microsoft.Toolkit.Win32.UI.XamlApplication" version="6.1.3" targetFramework="native" />
<package id="Microsoft.UI.Xaml" version="2.8.2-prerelease.220830001" targetFramework="native" />
<package id="Microsoft.VCRTForwarders.140" version="1.0.7" targetFramework="native" />
<package id="Microsoft.Web.WebView2" version="1.0.4022.49" targetFramework="native" />
<package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" />
<package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" />
</packages>

View File

@@ -15,6 +15,7 @@
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir>
</PropertyGroup>
<PropertyGroup Label="Configuration">
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
@@ -100,7 +101,7 @@
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets" Condition="Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" />
</ImportGroup>
<Import Project="..\..\..\..\deps\spdlog.props" />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
@@ -113,7 +114,7 @@
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" />
</Target>
<Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild">
<Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory)\..\KeyboardManagerEditor\ resource.base.h resource.h KeyboardManagerEditor.base.rc KeyboardManagerEditor.rc" />

View File

@@ -2,6 +2,6 @@
<packages>
<package id="Microsoft.Toolkit.Win32.UI.XamlApplication" version="6.1.3" targetFramework="native" />
<package id="Microsoft.UI.Xaml" version="2.8.2-prerelease.220830001" targetFramework="native" />
<package id="Microsoft.Web.WebView2" version="1.0.4022.49" targetFramework="native" />
<package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" />
<package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" />
</packages>

View File

@@ -212,6 +212,12 @@
<TextBlock x:Uid="ActionType_Disable_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem Tag="PowerScript">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE756;" />
<TextBlock Text="PowerScript" />
</StackPanel>
</ComboBoxItem>
<!--
<ComboBoxItem x:Uid="ActionType_MouseClick" Tag="MouseClick">
<StackPanel Orientation="Horizontal" Spacing="8">
@@ -398,6 +404,27 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
</tkcontrols:Case>
<!-- PowerScript Action -->
<tkcontrols:Case Value="PowerScript">
<StackPanel Orientation="Vertical" Spacing="8">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Run a PowerScript when this shortcut is pressed."
TextWrapping="Wrap" />
<ComboBox
x:Name="PowerScriptComboBox"
HorizontalAlignment="Stretch"
DisplayMemberPath="Name"
PlaceholderText="Select a PowerScript"
SelectionChanged="PowerScriptComboBox_SelectionChanged" />
<TextBlock
x:Name="PowerScriptEmptyHint"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="No PowerScripts found. Enable the PowerScripts module and add a system script."
TextWrapping="Wrap"
Visibility="Collapsed" />
</StackPanel>
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
</StackPanel>
<!-- Validation InfoBar spanning all columns -->

View File

@@ -34,6 +34,8 @@ namespace KeyboardManagerEditorUI.Controls
private readonly ObservableCollection<string> _triggerKeys = new();
private readonly ObservableCollection<string> _actionKeys = new();
private readonly ObservableCollection<Helpers.PowerScriptInfo> _powerScripts = new();
private bool _disposed;
private bool _internalUpdate;
@@ -79,6 +81,7 @@ namespace KeyboardManagerEditorUI.Controls
OpenApp,
MouseClick,
Disable,
PowerScript,
}
/// <summary>
@@ -132,6 +135,7 @@ namespace KeyboardManagerEditorUI.Controls
"OpenApp" => ActionType.OpenApp,
"MouseClick" => ActionType.MouseClick,
"Disable" => ActionType.Disable,
"PowerScript" => ActionType.PowerScript,
_ => ActionType.KeyOrShortcut,
};
}
@@ -151,6 +155,14 @@ namespace KeyboardManagerEditorUI.Controls
TriggerKeys.ItemsSource = _triggerKeys;
ActionKeys.ItemsSource = _actionKeys;
// Populate the PowerScripts picker (system scripts). Empty when PowerScripts isn't installed.
foreach (var script in Helpers.PowerScriptsCatalog.GetSystemScripts())
{
_powerScripts.Add(script);
}
PowerScriptComboBox.ItemsSource = _powerScripts;
_triggerKeys.CollectionChanged += (_, _) =>
{
UpdatePlaceholderVisibility();
@@ -267,6 +279,18 @@ namespace KeyboardManagerEditorUI.Controls
RaiseValidationStateChanged();
}
private void PowerScriptComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (PowerScriptEmptyHint != null)
{
PowerScriptEmptyHint.Visibility = _powerScripts.Count == 0
? Microsoft.UI.Xaml.Visibility.Visible
: Microsoft.UI.Xaml.Visibility.Collapsed;
}
RaiseValidationStateChanged();
}
private void ActionKeyToggleBtn_Checked(object sender, RoutedEventArgs e)
{
if (ActionKeyToggleBtn.IsChecked == true)
@@ -752,6 +776,26 @@ namespace KeyboardManagerEditorUI.Controls
/// </summary>
public string GetUrl() => UrlPathInput?.Text ?? string.Empty;
/// <summary>
/// Gets the selected PowerScript (for the PowerScript action type), or null if none selected.
/// </summary>
public Helpers.PowerScriptInfo? GetSelectedPowerScript() => PowerScriptComboBox?.SelectedItem as Helpers.PowerScriptInfo;
/// <summary>
/// Selects the PowerScript with the given id in the picker, if present.
/// </summary>
public void SelectPowerScript(string id)
{
foreach (var script in _powerScripts)
{
if (string.Equals(script.Id, id, StringComparison.OrdinalIgnoreCase))
{
PowerScriptComboBox.SelectedItem = script;
return;
}
}
}
/// <summary>
/// Gets the program path (for OpenApp action type).
/// </summary>

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace KeyboardManagerEditorUI.Helpers
{
/// <summary>
/// A single PowerScript entry as surfaced to the Keyboard Manager editor's "PowerScript" action picker.
/// </summary>
public sealed class PowerScriptInfo
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Kind { get; init; } = string.Empty;
public override string ToString() => Name;
}
}

View File

@@ -0,0 +1,147 @@
// 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace KeyboardManagerEditorUI.Helpers
{
/// <summary>
/// Bridges the Keyboard Manager editor to the PowerScripts module.
///
/// PowerScripts are surfaced through the shared executor <c>PowerScripts.Host.exe</c>. To keep the
/// editor decoupled from the PowerScripts assemblies, we shell out to <c>Host.exe list --json</c>
/// and parse the result. Selecting a "system" PowerScript in the editor then saves an ordinary
/// Keyboard Manager "Run Program" mapping whose target is <c>Host.exe run &lt;id&gt;</c>.
/// </summary>
public static class PowerScriptsCatalog
{
private const string HostExeName = "PowerScripts.Host.exe";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Resolves the full path to <c>PowerScripts.Host.exe</c>, or null if it can't be found.
/// Search order: explicit override env var, next to the editor, then the default install root.
/// </summary>
public static string? ResolveHostPath()
{
var overridePath = Environment.GetEnvironmentVariable("POWERSCRIPTS_HOST");
if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath))
{
return overridePath;
}
var candidates = new List<string>
{
Path.Combine(AppContext.BaseDirectory, HostExeName),
Path.Combine(AppContext.BaseDirectory, "PowerScripts", HostExeName),
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"PowerScripts",
HostExeName),
};
// Prototype dev fallback: in an in-repo build the Host isn't copied next to the editor,
// so walk up from the base directory and probe the Host project's bin output. This keeps
// the PowerScript action usable for end-to-end testing from a Debug build.
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
foreach (var config in new[] { "Debug", "Release" })
{
var hostBin = Path.Combine(
dir.FullName,
"src",
"modules",
"PowerScripts",
"PowerScripts.Host",
"bin",
config);
if (Directory.Exists(hostBin))
{
var found = Directory
.EnumerateFiles(hostBin, HostExeName, SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrEmpty(found))
{
candidates.Add(found);
}
}
}
dir = dir.Parent;
}
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
/// <summary>
/// Returns the list of system PowerScripts available for hotkey assignment, or an empty list
/// when PowerScripts isn't installed or no system scripts exist.
/// </summary>
public static IReadOnlyList<PowerScriptInfo> GetSystemScripts()
{
var hostPath = ResolveHostPath();
if (hostPath is null)
{
return Array.Empty<PowerScriptInfo>();
}
try
{
var psi = new ProcessStartInfo
{
FileName = hostPath,
Arguments = "list --json",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process is null)
{
return Array.Empty<PowerScriptInfo>();
}
string json = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
var all = JsonSerializer.Deserialize<List<PowerScriptInfo>>(json, JsonOptions) ?? new List<PowerScriptInfo>();
var systemScripts = new List<PowerScriptInfo>();
foreach (var script in all)
{
if (string.Equals(script.Kind, "system", StringComparison.OrdinalIgnoreCase))
{
systemScripts.Add(script);
}
}
return systemScripts;
}
catch (Exception)
{
// Prototype: a missing/failed PowerScripts host simply yields no scripts to pick.
return Array.Empty<PowerScriptInfo>();
}
}
}
}

View File

@@ -394,6 +394,7 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.ActionType.OpenUrl => SaveUrlMapping(triggerKeys),
UnifiedMappingControl.ActionType.OpenApp => SaveProgramMapping(triggerKeys),
UnifiedMappingControl.ActionType.Disable => SaveDisableMapping(triggerKeys),
UnifiedMappingControl.ActionType.PowerScript => SavePowerScriptMapping(triggerKeys),
UnifiedMappingControl.ActionType.MouseClick => throw new NotImplementedException("Mouse click remapping is not yet supported."),
_ => false,
};
@@ -439,6 +440,10 @@ namespace KeyboardManagerEditorUI.Pages
triggerKeys, UnifiedMappingControl.GetProgramPath(), isAppSpecific, appName, _mappingService!, _isEditMode),
UnifiedMappingControl.ActionType.Disable => ValidationHelper.ValidateDisableMapping(
triggerKeys, isAppSpecific, appName, _mappingService!, _isEditMode, editingRemapping),
UnifiedMappingControl.ActionType.PowerScript => UnifiedMappingControl.GetSelectedPowerScript() is null
? ValidationErrorType.EmptyProgramPath
: ValidationHelper.ValidateAppMapping(
triggerKeys, PowerScriptsCatalog.ResolveHostPath() ?? string.Empty, isAppSpecific, appName, _mappingService!, _isEditMode),
_ => ValidationErrorType.NoError,
};
}
@@ -683,6 +688,47 @@ namespace KeyboardManagerEditorUI.Pages
return saved;
}
private bool SavePowerScriptMapping(List<string> triggerKeys)
{
var script = UnifiedMappingControl.GetSelectedPowerScript();
if (script is null)
{
return false;
}
string hostPath = PowerScriptsCatalog.ResolveHostPath() ?? string.Empty;
if (string.IsNullOrEmpty(hostPath))
{
return false;
}
string originalKeysString = string.Join(";", triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture)));
// A PowerScript hotkey is an ordinary "Run Program" mapping that invokes the shared executor.
var shortcutKeyMapping = new ShortcutKeyMapping
{
OperationType = ShortcutOperationType.RunProgram,
OriginalKeys = originalKeysString,
TargetKeys = originalKeysString,
ProgramPath = hostPath,
ProgramArgs = $"run {script.Id}",
StartInDirectory = string.Empty,
IfRunningAction = ProgramAlreadyRunningAction.StartAnother,
Visibility = StartWindowType.Hidden,
Elevation = ElevationLevel.NonElevated,
TargetApp = string.Empty,
};
bool saved = _mappingService!.AddShortcutMapping(shortcutKeyMapping);
if (saved)
{
_mappingService.SaveSettings();
SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping);
}
return saved;
}
#endregion
#region Delete Handlers

View File

@@ -33,40 +33,16 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
return null;
}
var isFileUri = string.Equals(
rfc3986Uri.Scheme,
Uri.UriSchemeFile,
StringComparison.OrdinalIgnoreCase);
var isUncFileUri =
isFileUri &&
string.IsNullOrEmpty(authority) &&
!string.IsNullOrEmpty(rfc3986Uri.Authority) &&
!string.Equals(
rfc3986Uri.Authority,
"localhost",
StringComparison.OrdinalIgnoreCase);
// file://server/share is a local Windows UNC path, not a VS Code remote URI.
var effectiveAuthority =
isFileUri && string.IsNullOrEmpty(authority)
? string.Empty
: authority ?? rfc3986Uri.Authority;
var (workspaceEnv, machineName) =
ParseVSCodeAuthority.GetWorkspaceEnvironment(effectiveAuthority);
var (workspaceEnv, machineName) = ParseVSCodeAuthority.GetWorkspaceEnvironment(authority ?? rfc3986Uri.Authority);
if (workspaceEnv is null)
{
return null;
}
var path = isUncFileUri
? $@"\\{rfc3986Uri.Authority}{rfc3986Uri.Path.Replace('/', '\\')}"
: rfc3986Uri.Path;
var path = rfc3986Uri.Path;
// file:///C:/... becomes C:/...
if (workspaceEnv == WorkspaceEnvironment.Local && !isUncFileUri)
// Remove preceding '/' from local (Windows) path
if (workspaceEnv == WorkspaceEnvironment.Local)
{
path = path[1..];
}

View File

@@ -1,72 +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.Text.Json;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerDisplay.UnitTests;
/// <summary>
/// Covers the persisted shape of the mouse-wheel-increment setting on
/// <see cref="PowerDisplayProperties"/>: its default of 5 (the historical hardcoded step),
/// its snake_case JSON key, round-trip fidelity, and the forward-compatibility promise that
/// settings.json written before the feature existed deserializes to the default of 5 with no
/// migration.
/// </summary>
[TestClass]
public class MouseWheelIncrementSettingsTests
{
[TestMethod]
public void Default_IsFive()
{
var properties = new PowerDisplayProperties();
Assert.AreEqual(5, properties.MouseWheelIncrement, "Default must preserve the historical hardcoded step of 5.");
}
[TestMethod]
public void Deserialize_LegacyJsonMissingField_DefaultsToFive()
{
// A settings.json captured before this feature shipped has no mouse_wheel_increment key.
// Deserializing must fall back to the constructor default of 5, not 0. System.Text.Json
// calls the parameterless constructor (which sets MouseWheelIncrement = 5) and then fills
// only the fields present in JSON. If PowerDisplayProperties ever gains a
// [JsonConstructor]-annotated constructor, re-verify this "defaults to 5" behavior.
const string legacyJson = """
{
"monitor_refresh_delay": 5,
"restore_settings_on_startup": false,
"show_system_tray_icon": true
}
""";
var properties = JsonSerializer.Deserialize<PowerDisplayProperties>(legacyJson);
Assert.IsNotNull(properties);
Assert.AreEqual(5, properties.MouseWheelIncrement);
}
[TestMethod]
public void RoundTrip_PreservesValue()
{
var original = new PowerDisplayProperties { MouseWheelIncrement = 15 };
var json = JsonSerializer.Serialize(original);
var restored = JsonSerializer.Deserialize<PowerDisplayProperties>(json);
Assert.IsNotNull(restored);
Assert.AreEqual(15, restored.MouseWheelIncrement);
}
[TestMethod]
public void Serialize_UsesSnakeCaseJsonKey()
{
var properties = new PowerDisplayProperties { MouseWheelIncrement = 10 };
var json = JsonSerializer.Serialize(properties);
StringAssert.Contains(json, "\"mouse_wheel_increment\":10");
}
}

View File

@@ -8,7 +8,7 @@ namespace PowerDisplay.Helpers
{
/// <summary>
/// PowerDisplay-local window helpers. Flyout positioning/sizing now lives in
/// <c>Microsoft.PowerToys.Common.UI.Controls.Window.FlyoutWindowHelper</c> (Common.UI.Controls).
/// <c>Microsoft.PowerToys.Common.UI.Flyout.FlyoutWindowHelper</c> (Common.UI.Controls).
/// </summary>
internal static partial class WindowHelper
{

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using PowerDisplay.Configuration;

View File

@@ -235,7 +235,7 @@
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="{x:Bind ViewModel.MouseWheelIncrement, Mode=OneWay}"
helpers:SliderExtensions.MouseWheelChange="5"
IsEnabled="{x:Bind ViewModel.IsLinkedBrightnessSliderEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"
@@ -525,7 +525,7 @@
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
helpers:SliderExtensions.MouseWheelChange="5"
IsEnabled="{x:Bind IsBrightnessSliderEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"
@@ -556,7 +556,7 @@
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
helpers:SliderExtensions.MouseWheelChange="5"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"
@@ -586,7 +586,7 @@
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
helpers:SliderExtensions.MouseWheelChange="5"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"

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