mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 16:09:46 +02:00
Compare commits
58 Commits
dev/mjolle
...
powerscrip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24ce23fa12 | ||
|
|
6d4a1dee6e | ||
|
|
cd327dda07 | ||
|
|
54bd07c08d | ||
|
|
35ccc7658d | ||
|
|
b58b2f1a4c | ||
|
|
0188c1ac69 | ||
|
|
11bda2709b | ||
|
|
a618b2f2f9 | ||
|
|
3cdbca3fa6 | ||
|
|
be711d12bf | ||
|
|
29ca6328f9 | ||
|
|
af2c3c61cd | ||
|
|
a864d421fc | ||
|
|
3331bdf02a | ||
|
|
9ee0c7259b | ||
|
|
7e877558b9 | ||
|
|
386a16ff94 | ||
|
|
ada75d040a | ||
|
|
086bd06eaf | ||
|
|
205ae601ce | ||
|
|
80f2b9b07d | ||
|
|
5f1b496bf2 | ||
|
|
a2218b3c3b | ||
|
|
47335149f3 | ||
|
|
5c63486dcb | ||
|
|
bf6dbd8865 | ||
|
|
3c942ae356 | ||
|
|
be4c3a1afa | ||
|
|
968a7ac4b6 | ||
|
|
dd26d86580 | ||
|
|
4771f15b6c | ||
|
|
ed93fb585a | ||
|
|
c3bec3935a | ||
|
|
334d1c8054 | ||
|
|
5c16c97db5 | ||
|
|
a255ece641 | ||
|
|
106c970c8c | ||
|
|
ff8c1dbf85 | ||
|
|
cd7465e22a | ||
|
|
502dc40aa4 | ||
|
|
b5bd626d70 | ||
|
|
ee7d4a1938 | ||
|
|
a9e1f0f20e | ||
|
|
1ce0509fc7 | ||
|
|
b848c70071 | ||
|
|
9f7a726a7c | ||
|
|
32ad98a0dd | ||
|
|
2ffc248792 | ||
|
|
cabb71108a | ||
|
|
a0e53de825 | ||
|
|
eab305334b | ||
|
|
c41ac6df87 | ||
|
|
e1b1a8d7ed | ||
|
|
19c2c28dd9 | ||
|
|
459bd56fb6 | ||
|
|
e3a1ee2e5b | ||
|
|
d2aa24786d |
4
.github/actions/spell-check/allow/code.txt
vendored
4
.github/actions/spell-check/allow/code.txt
vendored
@@ -17,8 +17,8 @@ LIGHTTURQUOISE
|
||||
NCol
|
||||
OLIVEGREEN
|
||||
PALEBLUE
|
||||
PArgb
|
||||
Pbgra
|
||||
pargb
|
||||
pbgra
|
||||
SRGBTo
|
||||
WHITEONBLACK
|
||||
|
||||
|
||||
15
.github/actions/spell-check/expect.txt
vendored
15
.github/actions/spell-check/expect.txt
vendored
@@ -415,7 +415,6 @@ DISPLAYFLAGS
|
||||
DISPLAYFREQUENCY
|
||||
displayname
|
||||
DISPLAYORIENTATION
|
||||
DISPLAYPORT
|
||||
divyan
|
||||
DLGFRAME
|
||||
dlgmodalframe
|
||||
@@ -622,7 +621,9 @@ GETPROPERTYSTOREFLAGS
|
||||
GETSCREENSAVERRUNNING
|
||||
GETSECKEY
|
||||
GETSTICKYKEYS
|
||||
GETTASKBARPOS
|
||||
GETTEXTLENGTH
|
||||
GETWORKAREA
|
||||
gfx
|
||||
GHND
|
||||
gitmodules
|
||||
@@ -862,7 +863,6 @@ jjw
|
||||
jobject
|
||||
JOBOBJECT
|
||||
jpe
|
||||
JPN
|
||||
jpnime
|
||||
jrsoftware
|
||||
Jsons
|
||||
@@ -995,7 +995,6 @@ LTM
|
||||
LTRREADING
|
||||
luid
|
||||
lusrmgr
|
||||
LVDS
|
||||
LWA
|
||||
LZero
|
||||
MAGTRANSFORM
|
||||
@@ -1059,8 +1058,6 @@ MINIMIZESTART
|
||||
MINMAXINFO
|
||||
minwindef
|
||||
Mip
|
||||
Miracast
|
||||
miracast
|
||||
mkdn
|
||||
mlcfg
|
||||
mmc
|
||||
@@ -1365,6 +1362,7 @@ pii
|
||||
pinfo
|
||||
pinvoke
|
||||
pipename
|
||||
Pitjantjatjara
|
||||
PKBDLLHOOKSTRUCT
|
||||
pkgfamily
|
||||
PKI
|
||||
@@ -1388,6 +1386,7 @@ popups
|
||||
POPUPWINDOW
|
||||
portfile
|
||||
POSITIONITEM
|
||||
Postbot
|
||||
POWERBROADCAST
|
||||
powerdisplay
|
||||
POWERDISPLAYMODULEINTERFACE
|
||||
@@ -1507,6 +1506,7 @@ RAWPATH
|
||||
rbhid
|
||||
Rbuttondown
|
||||
rclsid
|
||||
RCW
|
||||
RCZOOMIT
|
||||
rdp
|
||||
RDW
|
||||
@@ -1639,6 +1639,7 @@ SETPOWEROFFACTIVE
|
||||
SETRANGE
|
||||
SETREDRAW
|
||||
SETRULES
|
||||
SETAUTOHIDEBAREX
|
||||
SETSCREENSAVEACTIVE
|
||||
SETSTICKYKEYS
|
||||
SETTEXT
|
||||
@@ -1817,7 +1818,6 @@ svchost
|
||||
SVGIn
|
||||
SVGIO
|
||||
svgz
|
||||
SVIDEO
|
||||
SVSI
|
||||
SWFO
|
||||
swp
|
||||
@@ -1916,6 +1916,7 @@ tracerpt
|
||||
trackbar
|
||||
trafficmanager
|
||||
transicc
|
||||
transitioning
|
||||
TRAYMOUSEMESSAGE
|
||||
triaging
|
||||
trl
|
||||
@@ -2081,6 +2082,7 @@ winapi
|
||||
winappsdk
|
||||
windir
|
||||
WINDOWCREATED
|
||||
WINDOWDESTROYED
|
||||
windowedge
|
||||
WINDOWINFO
|
||||
WINDOWNAME
|
||||
@@ -2184,6 +2186,7 @@ XUP
|
||||
XVIRTUALSCREEN
|
||||
XXL
|
||||
xxxxxx
|
||||
Yankunytjatjara
|
||||
ycombinator
|
||||
yinle
|
||||
yinyue
|
||||
|
||||
201
.github/skills/powertoys-module-verification/LICENSE.txt
vendored
Normal file
201
.github/skills/powertoys-module-verification/LICENSE.txt
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor 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, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Microsoft Corporation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
302
.github/skills/powertoys-module-verification/SKILL.md
vendored
Normal file
302
.github/skills/powertoys-module-verification/SKILL.md
vendored
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
name: powertoys-module-verification
|
||||
description: "Verify a single PowerToys module's release checklist items end-to-end. Drive each checkbox via UIA / Named Events / settings.json edits / clipboard inspection / GPO / SendInput. Output a structured PASS / FAIL / BLOCKED verdict per item with evidence (FAIL distinguishes product defects from stale/ambiguous checklist items). Combine standard winapp ui mechanics (see references/winapp-ui-testing.md) with PT-specific recipes and the helper .ps1 files shipped with this skill."
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Use this skill when you need to **verify every checklist item for a single PowerToys module** for a release sign-off — e.g. "verify all 18 Color Picker items", "verify all 88 Command Palette items". Each item produces a PASS / FAIL / BLOCKED verdict with evidence (UIA enumeration, log line, settings.json diff, screenshot, etc.).
|
||||
|
||||
The **checklist to verify is supplied with the task** (the calling prompt points you at the module's checklist file). This skill is the *how* — the drive techniques, helpers, taxonomy, and reporting format — independent of any specific checklist.
|
||||
|
||||
## Required reads (in order)
|
||||
|
||||
1. **`references/winapp-ui-testing.md`** — the **prerequisite** UIA mechanics doc (winapp ui verbs, scripted batch testing, file pickers, accessibility audits, screenshots, click-vs-invoke, PostMessage, SendInput cb=40, stunted-UIA recovery, settings-mutation safety contract). **Read this first** — this skill assumes you know its content and only adds PT-specific extensions.
|
||||
2. **This `SKILL.md`** — the PT-specific playbook: the 3-bucket drive-technique selector (Step 2), classification taxonomy, critical pitfalls, helper-script catalog.
|
||||
3. **`references/modules/<module>.md` IF IT EXISTS** — per-module entry-paths, item-by-item recipes, common BLOCKED traps, fixture lists, source citations. **Always check `references/modules/` first.** If no profile exists, fall back to this SKILL.md and create one after you finish (template in `references/modules/README.md`).
|
||||
4. **`references/explorer-context-menu-flow.md` IF your module registers an Explorer right-click entry** (PowerRename, File Locksmith, Image Resizer, New+, Preview Pane, RegistryPreview) — shared synthetic-right-click + UIA-invoke + multi-file-selection flow + module-caption table. Helper: `scripts/pt-explorer-contextmenu.ps1`.
|
||||
5. **`references/pre-flight.md`** — pre-flight checks, bootstrap snippet, state-hygiene cleanup, final wrap-up, hard rules.
|
||||
6. **`references/reporting-format.md`** — per-item table template, top-of-report summary, step-table rules, anti-patterns, worked example.
|
||||
7. **`references/environment-setup.md`** — RDP/sleep/screensaver/session-attachment gotchas. Cite in BLK-ENV verdicts.
|
||||
8. **`references/release-checklist/<module>.md`** — the checklist for the module under test (one file per module; see `references/release-checklist/index.md` for the full list). Each item carries `[ADMIN: …]` + `[CLARITY: …]` metadata. **This file IS the set of items to verify.**
|
||||
|
||||
## Helper scripts shipped with this skill
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `scripts/pt-shared-events.ps1` | `Invoke-PtSharedEvent`, `Test-PtSharedEvent`, `Get-PtSharedEventCatalog` — 56-entry friendly-name map for PT Named Events (CmdPal.Show, AOT.Pin, PowerLauncher.Invoke, LightSwitch.Toggle, ZoomIt.Draw, ...). The deterministic, foreground-free, UIPI-immune way to trigger a module. |
|
||||
| `scripts/pt-sendinput-chord.ps1` | `Send-PtChord`, `Wait-PtHotkeyAccepted` — last-resort SendInput hotkey injection with the cb=40 fix. Use only when the module has no Named Event and the hotkey itself is the test subject. |
|
||||
| `scripts/pt-foreground-guard.ps1` | `Test-PtForeground`, `Force-PtForeground`, `Assert-PtForegroundOrAbort` — guard helpers to ensure target window IS foreground before SendInput, so keys don't leak to caller's terminal. |
|
||||
| `scripts/pt-cmdpal-recycle.ps1` | `Reset-CmdPalAppX`, `Reset-CmdPalToHome`, `Test-CmdPalDegraded`, `Invoke-CmdPalQuery` — CmdPal-specific lifecycle (handles TextChanged-broken state, BackButton navigation, AppX recycle). |
|
||||
| `scripts/pt-admin-probe.ps1` | `Test-PtAdmin`, `Test-ProcessElevated`, `Test-PtRunnerAdmin` — TokenElevation probes to verify your session and the PT runner have the right elevation for the test. |
|
||||
| `scripts/pt-clipboard-diff.ps1` | `Get-PtClipboardFormats`, `Compare-PtClipboardFormatDiff`, `Set-PtClipboardRich` — multi-format clipboard inspection for Advanced Paste tests. |
|
||||
| `scripts/pt-explorer-com.ps1` | `Get-PtExplorerWindows`, `Open-PtExplorerAtPath`, `Select-PtExplorerFiles`, `Invoke-PtPeekWithExplorerSelection`, `Test-PtInteractiveDesktop` — drive Explorer via Shell COM to set up multi-file selections, then trigger Peek/FZ/PowerRename/Image Resizer/Workspaces via their hotkeys. **Use this for Peek L706-L709, L719-L720 and any test that needs an Explorer file selection.** |
|
||||
| `scripts/pt-explorer-contextmenu.ps1` | `Test-PtDesktopInteractive`, `Open-PtExplorerContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems` — open Win11's real context menu via synthetic right-click (with retry), then UIA-invoke a menu item by name. **Canonical user-flow path for File Locksmith / Image Resizer / PowerRename / New+ menu-presence + launch tests.** Needs an unlocked interactive desktop. See `references/explorer-context-menu-flow.md` for the full write-up, stability notes, and per-module captions. |
|
||||
| `scripts/pt-shell-verbs.ps1` | `Get-PtShellVerbs`, `Invoke-PtShellVerb`, `Reset-PtShellComCache` — enumerate + invoke CLASSIC HKCR shell verbs via Shell.Application COM. **NOT for PT context-menu modules on Win11** (PT registers via IExplorerCommand, not classic — use `pt-explorer-contextmenu.ps1` for those). Useful for non-PT verbs (Open/Edit/Send-to/third-party) and as a negative check that PT verbs are NOT classic-shadowed. |
|
||||
| `scripts/pt-state.ps1` | `Get-PtSettings`, `Get-PtModuleSettings`, `Get-CmdPalSettings`, `Get-PtRunnerLogTail`, `Test-PtModuleEnabled`, `Test-PtModuleProcess`, `Restart-PtRunner`, `Backup-PtModuleSettings`, `Restore-PtModuleSettings` — common state checks. |
|
||||
| `scripts/pt-nonelevated.ps1` | `Start-PtNonElevated`, `Invoke-PtNonElevatedCapture` — launch an exe at **Medium IL (non-elevated)** from an elevated agent shell via a one-shot `RunLevel Limited` scheduled task. Required for elevation-visibility tests (a non-elevated module must NOT see higher-integrity processes; e.g. File Locksmith L649/L650). Verify the result with `Test-ProcessElevated`. |
|
||||
|
||||
Dot-source them **all** at once in your bootstrap (the `Get-ChildItem` loop loads every helper — see **Step 1 — Bootstrap**):
|
||||
```powershell
|
||||
$skill = '<this skill folder>' # the folder containing SKILL.md, e.g. <PT-repo>\.github\skills\powertoys-module-verification
|
||||
Get-ChildItem "$skill\scripts" -Filter '*.ps1' | ForEach-Object { . $_.FullName }
|
||||
```
|
||||
|
||||
## Step 1 — Bootstrap
|
||||
|
||||
```powershell
|
||||
$module = 'AdvancedPaste' # or 'CmdPal', 'FZ', 'Peek', ...
|
||||
# Work out of %TEMP% during the run (keeps screenshots/scratch off OneDrive); move to the
|
||||
# sign-off archive at the very end (see Step 7).
|
||||
$workspace = "$env:TEMP\verify-$module-$(Get-Date -Format yyyyMMdd-HHmmss)"
|
||||
New-Item -ItemType Directory -Path $workspace, "$workspace\artifacts" -Force | Out-Null
|
||||
$report = "$workspace\verify-$module.md"
|
||||
|
||||
# Dot-source helpers
|
||||
$skill = '<this skill folder>' # set once at top of your script (the folder containing SKILL.md)
|
||||
Get-ChildItem "$skill\scripts" -Filter '*.ps1' | ForEach-Object { . $_.FullName }
|
||||
|
||||
# Verify environment
|
||||
"=== Environment ===" | Tee-Object $report -Append
|
||||
"IsAdmin: $(Test-PtAdmin)" | Tee-Object $report -Append
|
||||
$rn = Test-PtRunnerAdmin
|
||||
"PT runner: PID=$($rn.Pid) Elevated=$($rn.Elevated)" | Tee-Object $report -Append
|
||||
|
||||
# The checklist items to verify are supplied with the task (see the calling prompt).
|
||||
# Read that module's checklist file and iterate its items (see Step 6 — Verifier loop).
|
||||
```
|
||||
|
||||
## Step 2 — Drive techniques
|
||||
|
||||
Every checklist item boils down to ONE of three intents. **Pick the bucket from the verb in the item, then use the best technique inside it.** Stop at the first technique that works.
|
||||
|
||||
| Intent | Verb-cues in the checklist item | Bucket |
|
||||
|---|---|---|
|
||||
| Change a setting | "default is X", "setting persists", "is enabled/disabled by default", "value Y is accepted" | §2.A |
|
||||
| Interact with a UI element | "click X", "toggle X", "type into Y", "X button is visible", "selecting Z does W" | §2.B |
|
||||
| Trigger a module action | "pressing hotkey X opens Y", "module launches", "Z happens when invoked" | §2.C |
|
||||
|
||||
### §2.A — Change a setting (single technique)
|
||||
|
||||
Edit the JSON file the module reads, wait for the file-watcher debounce, assert, then restore from backup. Zero external tools.
|
||||
|
||||
```powershell
|
||||
$bk = Backup-PtModuleSettings -ModuleDir AdvancedPaste
|
||||
try {
|
||||
$j = Get-PtModuleSettings -ModuleDir AdvancedPaste
|
||||
$j.properties.IsAdvancedAIEnabled.value = $false
|
||||
$j | ConvertTo-Json -Depth 12 | Set-Content "$env:LOCALAPPDATA\Microsoft\PowerToys\AdvancedPaste\settings.json"
|
||||
Start-Sleep -Seconds 4 # debounce — runner re-reads via file-watcher
|
||||
# ... assertion ...
|
||||
} finally {
|
||||
Restore-PtModuleSettings -ModuleDir AdvancedPaste -BackupPath $bk
|
||||
}
|
||||
```
|
||||
|
||||
> For shell-extension modules (PowerRename, File Locksmith, Image Resizer, New+) edit the **module-owned** file under `%LOCALAPPDATA%\Microsoft\PowerToys\<Module>\`, then `Restart-PtRunner` (and on stubborn handlers, restart Explorer). See pitfall #18 below.
|
||||
>
|
||||
> If you need to flip the *enabled* bit for a whole module, debounce isn't enough — call `Restart-PtRunner` after the write.
|
||||
|
||||
### §2.B — Interact with a UI element (2 techniques, most-reliable first)
|
||||
|
||||
#### B1. UIA invoke / set-value — **always try first**
|
||||
```powershell
|
||||
winapp ui invoke 'SubmitButton' -a PowerToys.Settings
|
||||
winapp ui set-value 'QueryTextBox' '=2+3*4' -a PowerToys.PowerLauncher
|
||||
Start-Sleep -Milliseconds 600
|
||||
winapp ui inspect -a PowerToys.PowerLauncher --depth 7 -i 2>$null
|
||||
```
|
||||
Invoke goes through UIA InvokePattern COM IPC — no foreground steal, no UIPI. See references/winapp-ui-testing.md §CRITICAL — invoke vs click.
|
||||
|
||||
#### B2. PostMessage WM_KEYDOWN/CHAR — when UIA can't reach the target
|
||||
For elevated targets, AppX windows with stunted UIA trees, or keystrokes that UIA `set-value` can't dispatch (arrow-key ListView nav, Enter to commit). See references/winapp-ui-testing.md §CRITICAL — Keystroke input that bypasses UIPI (PostMessage). Esc is often filtered by WinUI 3 raw-input hook — use BackButton invoke instead.
|
||||
|
||||
### §2.C — Trigger a module action (2 techniques, most-reliable first)
|
||||
|
||||
| | C1 Named Event | C2 SendInput chord |
|
||||
|---|---|---|
|
||||
| **Proves** | The action fires (the path *downstream* of the hotkey). **Not** that the chord is bound. | The full path: real keys → runner hook → action. The **only** method that proves the chord binding itself. |
|
||||
| **Robustness** | Highest — no foreground, no input desktop, UIPI-immune; works headless / RDP-minimized. | Lowest — needs an attached input desktop (else `BLK-ENV`), steals foreground, can't inject OS-reserved chords (Win+L / Win+Tab). |
|
||||
| **Precondition** | Owning module process is running (the event only exists while it is). | Attached input desktop + foreground. |
|
||||
|
||||
**Pick by what the item asserts:** for "does action Y happen" use C1; for "pressing chord X triggers Y" or "the rebind takes effect", C1 is insufficient (it bypasses the chord) — use C2, or C1 *plus* a runner-log line proving the chord was accepted.
|
||||
|
||||
#### C1. Named Event signal — preferred
|
||||
```powershell
|
||||
Invoke-PtSharedEvent -Name 'CmdPal.Show' # opens CmdPal without keyboard
|
||||
Invoke-PtSharedEvent -Name 'AOT.Pin' # pins foreground window via AOT
|
||||
Invoke-PtSharedEvent -Name 'PowerLauncher.Invoke' # opens PT Run
|
||||
Invoke-PtSharedEvent -Name 'LightSwitch.Toggle' # toggles theme
|
||||
Get-PtSharedEventCatalog | Format-Table # full list
|
||||
```
|
||||
No synthetic input — it's a `SetEvent` on the kernel event the module waits on, the same downstream path the runner's hotkey handler signals. Verify the side effect via UIA (`winapp ui list-windows -a <module>`), a log line (`Get-PtRunnerLogTail`), or settings.json diff (`Get-PtModuleSettings`). The event only exists while the owning process runs, so `Test-PtSharedEvent` doubles as an "is the module alive" check.
|
||||
|
||||
#### C2. SendInput chord — last resort / chord-binding verification
|
||||
Real synthetic keys. Loud (steals foreground) and fragile, but the only way to prove the activation chord is actually bound. The runner's global keyboard hook catches the chord regardless of focus, so the precondition is just an **attached input desktop** (pitfall #13; on a detached desktop `SendInput` returns `ACCESS_DENIED` and the keys vanish → mark `BLK-ENV`).
|
||||
```powershell
|
||||
# Precondition: input desktop attached? 0 = detached → don't bother sending, mark BLK-ENV (pitfall #13)
|
||||
if ([PtFg]::GetForegroundWindow() -eq [IntPtr]::Zero) { throw 'No input desktop — BLK-ENV (pitfall #13)' }
|
||||
|
||||
Send-PtChord -Mods 0x5B,0x10 -Key 0x43 # Win+Shift+C → Color Picker (cb=40 fix is inside the helper)
|
||||
$line = Wait-PtHotkeyAccepted -ModuleHint 'Color' -TimeoutSec 3
|
||||
if (-not $line) { throw 'Runner did not log hotkey invocation' }
|
||||
```
|
||||
|
||||
> **Rare fallback — a module that uses its own `RegisterHotKey` and exposes no Named Event.** Post `WM_HOTKEY` (`0x312`) straight to its message window (find the HWND via `EnumWindows`+`GetClassName` through `Add-Type` — same P/Invoke pattern as `pt-foreground-guard.ps1`). **No current PT module needs this:** ZoomIt — the obvious candidate — also waits on Named Events (`ZoomIt.Zoom`, `ZoomIt.Draw`, …; source: `Zoomit.cpp` `CreateEventW(ZOOMIT_ZOOM_EVENT)`), so drive it with C1.
|
||||
|
||||
> **Different case — sending keys *into* a specific focused window** (e.g. a CmdPal alias like `=` / `<` / `>` that `winapp ui set-value` can't trigger because it bypasses TextChanged; see pitfalls #4 and #6). Here the keystrokes go to whatever currently has focus, so you must bring the target window foreground first:
|
||||
> ```powershell
|
||||
> Assert-PtForegroundOrAbort -AppId Microsoft.CmdPal.UI # -AppId = the window you're typing INTO
|
||||
> Send-PtChord -Key 0xBB # '=' (no modifiers) to trigger the calculator alias
|
||||
> ```
|
||||
> The `-AppId` is whatever window you're targeting — it's **not** CmdPal-specific. CmdPal is just the worst offender: its AppX foreground-lock drops focus after the first `SetForegroundWindow`, so without the guard the keys silently leak to your terminal.
|
||||
|
||||
> Verdict decisions (PASS if behavior matches spec; **FAIL** if the product is wrong *or* the checklist item is stale/ambiguous; BLOCKED if you couldn't run the check after ≥2 entry-paths) live in **Step 3 — Classification taxonomy** below. Don't put verdict logic in Step 2.
|
||||
|
||||
## Step 3 — Classification taxonomy
|
||||
|
||||
### Verdicts (assign exactly ONE per item)
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---|---|
|
||||
| **PASS** | You drove/observed the behavior and it matched the spec. **A pass is a pass — there is no PASS sub-type.** Record *how* you verified in the item's **Category** field as free text, e.g. "full UIA flow + asserted popup", "settings.json round-trip", "runner-log line", "Shell COM / IExplorerCommand", "screenshot pixel-diff", "output matches fixture", "process spawn/exit", "module CLI", "admin GPO write". |
|
||||
| **FAIL** | The item is **red** — something is wrong and action is required. Treat the checklist as test code: a test fails because **the product is wrong** *or* **the test/checklist is wrong**. Record the **cause** in the **Category** field: <br>• **product** — behavior contradicts a valid spec → file a product bug (repro + expected-vs-actual + screenshot/log + build version). <br>• **checklist** — the item itself is broken: *stale* (feature was removed/deprecated — cite the source grep proving it's gone) or *ambiguous* (`[CLARITY: VAGUE-*]`, no definable pass/fail criterion — quote the original wording). Fix the checklist, not the product. |
|
||||
| **BLOCKED** | Couldn't run the check in this environment / with this toolset *after ≥2 entry-paths* — inconclusive, like a skipped test. **Not red against the product.** Tag exactly one concrete reason below. |
|
||||
|
||||
### BLOCKED reasons
|
||||
Different failure reasons stay distinct because each drives a different remediation.
|
||||
|
||||
| Reason | When |
|
||||
|---|---|
|
||||
| `BLK-ENV` | This specific shell can't drive it (non-interactive / Session 0, RDP-minimized, missing Explorer windows) but a normal interactive desktop CAN. Triggers a "re-run on an interactive desktop" recommendation. Cite `references/environment-setup.md`. |
|
||||
| `BLK-HARDWARE` | Needs hardware this session lacks — multi-monitor, 2 physical PCs (MWB), real camera / battery / game-mode, or live screen/device capture. State the specific shortfall in **Category**. |
|
||||
| `BLK-DRAG-REQUIRED` | Needs a real mouse-drag gesture; synthetic drag is insufficient (e.g. FancyZones snap). |
|
||||
| `BLK-DESTRUCTIVE` | Reboot, hibernate, install/uninstall, or mid-session AppX uninstall — would damage the run environment. |
|
||||
| `BLK-VISUAL-RENDER` | The thing to verify is a rendered surface UIA can't see — WinUI3 islands, WebView2, or Explorer-side context-menu rendering/localization. Needs pixel/OCR or a manual eyeball. |
|
||||
| `BLK-OVERLAY-INPUT-BLOCK` | Overlay both blocks input and excludes itself from capture (`BlockInput` + `WDA_EXCLUDEFROMCAPTURE`, e.g. ZoomIt draw mode) — can neither drive nor screenshot it. |
|
||||
| `BLK-EXTERNAL-APP` | Needs a 3rd-party tool, a real API key, or a system locale change. |
|
||||
|
||||
**Rule of thumb**: in your report, separate the two FAIL causes — *product* FAILs are bugs to file; *checklist* FAILs are items to rewrite or prune. `BLOCKED` is only for a concrete, named obstacle (cite it), never a substitute for effort. If a large share of a module's items are checklist-FAILs, the checklist needs an overhaul before re-verifying.
|
||||
|
||||
## Step 4 — Report format
|
||||
|
||||
**See `references/reporting-format.md` for the full template** (per-item table, summary, step-table rules, anti-patterns, worked example). Don't paraphrase; copy the templates literally. This includes a mandatory **§G Retrospective** — a self-reflection on the *run itself*: list every friction encountered (classified by source — `SKILL-UNCLEAR` / `WINAPP-TOOL-BUG` / `WINAPP-DOC-UNCLEAR` / `HELPER-FLAW` / `PT-PRODUCT` / `ENVIRONMENT` — with severity + minutes/attempts cost + a suggested fix), or write `Everything was smooth — no friction encountered.` if there was none. This is how the skill improves run over run, so don't skip it.
|
||||
|
||||
## Step 5 — State hygiene (CRITICAL)
|
||||
|
||||
**See `references/pre-flight.md` §State hygiene** for the backup/restore pattern and cleanup commands. Always wrap mutations in `try { ... } finally { Restore-* }`.
|
||||
|
||||
## Module-specific quick reference
|
||||
|
||||
**Look for `references/modules/<module>.md` FIRST.** Each per-module profile contains paths, entry-paths, item-by-item recipes, common BLOCKED traps, fixture lists, and source citations specific to that module.
|
||||
|
||||
Catalog: see `references/modules/README.md`. Currently authored: `peek.md`, `power-rename.md`, `file-locksmith.md`, `image-resizer.md`.
|
||||
|
||||
If your module has NO profile yet:
|
||||
1. Fall back to the generic drive-stack in §2 above.
|
||||
2. **For Explorer-context-menu modules** (PowerRename / File Locksmith / Image Resizer / New+ / Preview Pane / RegistryPreview): read **`references/explorer-context-menu-flow.md`** first — it has the synthetic-right-click + UIA-invoke pattern with stability rules and module-caption table. Per-module profiles cite it and only document module-specific quirks. The canonical helper is `scripts/pt-explorer-contextmenu.ps1`.
|
||||
3. After finishing the verification, **create the profile** using the template in `references/modules/README.md` so the next agent benefits from what you learned.
|
||||
|
||||
Quick one-liners for modules without dedicated profiles (will be moved to per-module files as they're authored):
|
||||
|
||||
- **Advanced Paste**: `Invoke-PtSharedEvent -Name 'AdvancedPaste.ShowUI'` + `Set-PtClipboardRich` + `Compare-PtClipboardFormatDiff` (see `scripts/pt-clipboard-diff.ps1`).
|
||||
- **Command Palette**: `Invoke-PtSharedEvent -Name 'CmdPal.Show'` + `Invoke-CmdPalQuery` (auto-handles degraded state via `scripts/pt-cmdpal-recycle.ps1`). Settings file via `Get-CmdPalSettings`.
|
||||
- **PowerToys Run**: `Invoke-PtSharedEvent -Name 'PowerLauncher.Invoke'` + `winapp ui set-value QueryTextBox`. Window has 2 HWNDs — filter by width ≥ 800.
|
||||
- **FancyZones**: `Invoke-PtSharedEvent -Name 'FancyZones.ToggleEditor'`. Snap-drag tests are usually `BLK-DRAG-REQUIRED`; settings verify via settings.json round-trip.
|
||||
- **Light Switch**: `Invoke-PtSharedEvent -Name 'LightSwitch.Toggle' | LightSwitch.Light | LightSwitch.Dark`. Verify via `HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`.
|
||||
- **Always on Top**: `Invoke-PtSharedEvent -Name 'AOT.Pin'`. Verify `WS_EX_TOPMOST` on pinned HWND.
|
||||
- **Hosts File Editor** (admin): `Invoke-PtSharedEvent -Name 'Hosts.Show' | Hosts.ShowAdmin`.
|
||||
- **GPO** (admin): write `HKLM:\Software\Policies\PowerToys` + `Restart-PtRunner` + `Get-PtRunnerLogTail -Pattern 'GPO sets'`. Cleanup: `Remove-Item HKLM:\Software\Policies\PowerToys -Recurse -Force`.
|
||||
- **Mouse Without Borders**: most items `BLK-HARDWARE` (need 2 physical PCs).
|
||||
- **ZoomIt**: most modes inside `BlockInput + WDA_EXCLUDEFROMCAPTURE` overlay → `BLK-OVERLAY-INPUT-BLOCK`. Mode triggers: `ZoomIt.Zoom`, `ZoomIt.Draw`, `ZoomIt.Break`, etc.
|
||||
- **Peek**: see `references/modules/peek.md` for the full recipe (CLI back-door + Shell.Application + Ctrl+Space).
|
||||
|
||||
## Step 6 — Verifier loop per checkbox
|
||||
|
||||
```
|
||||
For each item in module:
|
||||
1. Pick a bucket from the verb in the item (§2.A change a setting / §2.B interact with UI / §2.C trigger an action)
|
||||
2. Walk that bucket's techniques top-to-bottom; stop at the first one that drives the item
|
||||
3. Compare observed behavior to the spec:
|
||||
• matches the spec → PASS (note the method in Category)
|
||||
• product behaves wrong → FAIL, cause=product (repro + expected/actual + screenshot/log + build)
|
||||
4. Checklist item itself is broken — feature removed from source, or spec too ambiguous to judge → FAIL, cause=checklist (cite the source proof / quote the wording)
|
||||
5. Couldn't drive it after ≥2 entry-paths → BLOCKED with a concrete reason (§3)
|
||||
6. Record verdict + evidence + cleanup
|
||||
7. Next item
|
||||
```
|
||||
|
||||
When done, run state hygiene cleanup, write the report **including the §G retrospective**, archive the workspace (Step 7), and exit.
|
||||
|
||||
## Step 7 — Archive the workspace to the sign-off folder (do this LAST)
|
||||
|
||||
The live run works out of `%TEMP%`, but the **final deliverable must live in the module sign-off archive** so reports persist and sync via OneDrive:
|
||||
|
||||
```powershell
|
||||
# After the report is written AND the artifact-existence check passes:
|
||||
$signoff = "$env:OneDrive\PowerToys\Module-Signoff" # e.g. C:\Users\<you>\OneDrive - Microsoft\PowerToys\Module-Signoff
|
||||
New-Item -ItemType Directory -Path $signoff -Force | Out-Null
|
||||
$final = Join-Path $signoff (Split-Path $workspace -Leaf)
|
||||
Move-Item -Path $workspace -Destination $final -Force
|
||||
# Report uses RELATIVE artifacts/… paths, so all links stay valid after the move.
|
||||
Write-Host "Final report: $(Join-Path $final (Split-Path $report -Leaf))"
|
||||
```
|
||||
|
||||
Print the **moved** report path (under `…\PowerToys\Module-Signoff\`) as the last line — never the `%TEMP%` path.
|
||||
|
||||
## Invocation & placeholders
|
||||
|
||||
This skill auto-activates when you ask to verify a PowerToys module's checklist (e.g. "verify all Color Picker items"). **One module per run** — never chain multiple modules into one report. Resolve these placeholders for the module under test:
|
||||
|
||||
| Placeholder | Substitute with |
|
||||
|---|---|
|
||||
| `<Module>` | Exact display name, e.g. `Color Picker`, `Command Palette`, `PowerToys Run`, `FancyZones` (see `references/release-checklist/index.md`). |
|
||||
| `<module>` | Lowercase-kebab-case for file lookup, e.g. `color-picker`, `command-palette`, `power-rename` — used for BOTH `references/release-checklist/<module>.md` (checklist) and `references/modules/<module>.md` (profile, if any). |
|
||||
| `<ModuleDir>` | settings.json sub-dir under `%LOCALAPPDATA%\Microsoft\PowerToys\` (e.g. `AdvancedPaste`, `FancyZones`, `PowerToys Run` (with space)). |
|
||||
| `<N>` | Total item count for this module. |
|
||||
|
||||
**Execution order:** `references/pre-flight.md` → per item, the §2 drive-stack (this file) → `references/reporting-format.md` per-item table → Step 6 verifier loop → `references/pre-flight.md` §Final wrap-up → Step 7 archive → print the final report path.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do NOT chain multiple modules in one report — one module per run.
|
||||
- Do NOT mark an item BLOCKED without a concrete, named obstacle (see §3 and `references/pre-flight.md` §Hard rules).
|
||||
- Do NOT invent steps for a VAGUE checklist item — if the spec is too ambiguous to judge, that is FAIL (cause=checklist), not a guess.
|
||||
- All other rules (foreground guard, always restore mutated state, etc.) live in `references/pre-flight.md` §Hard rules — follow them.
|
||||
|
||||
## Critical pitfalls (PT-specific)
|
||||
|
||||
*Reference, not a sequential step — skim before you start and consult while driving. Numbered for cross-reference only.*
|
||||
|
||||
1. **PT runner does NOT auto-pickup edits to master `settings.json`** (top-level `enabled.<Module>` flags). Call `Restart-PtRunner`.
|
||||
2. **Each module's own `<Module>\settings.json` IS hot-reloaded** via per-module file watcher (~3s debounce). **EXCEPTION — shell-extension/context-menu modules do NOT read this file; see pitfall #18.**
|
||||
3. **PT Run setting key has a space**: `"PowerToys Run"` not `PowerToysRun`.
|
||||
4. **CmdPal AppX foreground from external CLI is unreliable** — Windows foreground-lock blocks `SetForegroundWindow` after the first call. SendInput keys silently leak to your terminal. **Always `Assert-PtForegroundOrAbort` before SendInput.**
|
||||
5. **CmdPal AppX enters TextChanged-broken state** every ~30 probes — `Test-CmdPalDegraded` + `Reset-CmdPalAppX` to recover.
|
||||
6. **CmdPal alias detection (`=`, `<`, `>`, `:`, `$`, `??`, `)`) requires real keystrokes** — `winapp ui set-value` bypasses TextChanged and the alias never fires. Use Send-PtChord + `Assert-PtForegroundOrAbort` for aliases; use set-value for plain queries.
|
||||
7. **CmdPal Esc handler is filtered** by WinUI 3 raw-input hook — use `winapp ui invoke BackButton` instead (see `Reset-CmdPalToHome`).
|
||||
8. **GPO HKLM vs HKCU**: HKLM wins when both are set with conflicting values.
|
||||
9. **HKLM `Software\Policies\PowerToys` writes require admin** — verify with `Test-PtAdmin`.
|
||||
10. **`Stop-Process` is policy-blocked in this session unless you pass `-Id <int>` literally**. Always inline the PID.
|
||||
11. **WinUI 3 islands are largely invisible to UIA** (QuickAccess flyout, RegistryPreview Monaco editor, Peek WebView2). For these, fall back to screenshot + OCR or settings.json diff.
|
||||
12. **OS-reserved chords (Win+L, Win+Tab)** are consumed by Windows before any hook and cannot be injected via SendInput at all.
|
||||
13. **RDP minimized = `SendInput` denied.** Even though `quser` shows the remote session State=Active, minimizing the mstsc client detaches the session's input desktop. `GetForegroundWindow()` returns 0; `SendInput` returns `ACCESS_DENIED (5)`; tests that need synthetic input fail. **Same applies to: closed mstsc with X (Disconnected), local PC sleep (RDP TCP drops), remote screensaver/workstation lock, remote machine sleep.** Run `scripts/pt-session-diagnose.ps1` in pre-flight to detect, and see `references/environment-setup.md` for the full per-scenario table + `powercfg` setup commands the user should run before starting the agent. The agent should call `Test-PtForeground` mid-run before each input-injection-dependent item; if it returns False, mark `BLK-ENV` with mitigation citation (an environment block — not a product FAIL).
|
||||
14. **`winapp ui` arg-order quirk**: `winapp ui inspect --depth N -w $hwnd` may intermittently fail to parse `--depth` as Int64 if `-w` precedes it. **Put `-w $hwnd` AFTER `--depth N`** or as the first arg before any flag. If you see "Cannot bind argument" or numeric parse errors, swap the order and retry.
|
||||
15. **`winapp ui list-windows` line wrapping**: when window titles or process names are long, output may wrap a single window's `HWND <id>: "<title>" ... (proc, PID N)` across multiple lines, breaking single-line regexes. Either pipe through `Out-String` and use a multi-line regex, or use `--json` (when supported) and parse structured output.
|
||||
16. **De-elevation: launching a NON-elevated (Medium IL) child from an elevated agent shell.** The drive-stack only covers gaining *more* privilege; some items need the opposite. From a High-IL shell you cannot `Start-Process` a Medium-IL child directly. Use `scripts/pt-nonelevated.ps1` (`Start-PtNonElevated` / `Invoke-PtNonElevatedCapture`) — a one-shot `RunLevel Limited` + `LogonType Interactive` scheduled task that lands on the user's desktop at their filtered token. Confirm with `Test-ProcessElevated`. Needed for elevation-visibility pairs (File Locksmith L649/L650: non-elevated FL must not see the elevated runner; elevated FL must).
|
||||
17. **Win11 packaged context menus are not observable without real Explorer.** Modern PT context-menu entries are packaged `IExplorerCommand`s (sparse MSIX, e.g. File Locksmith CLSID `{AAF1E27D-…}`). They are **NOT** enumerable via classic `Shell.Application … FolderItem.Verbs()` and **NOT** `CoCreate`-able from a non-Explorer host (`REGDB_E_CLASSNOTREGISTERED`). So "verify the entry appears / no longer appears" cannot be pixel-verified by API. Verify instead via the gate flag the entry's `GetState` reads (e.g. general `enabled.<Module>`) + a source citation that maps it to `ECS_HIDDEN`; treat the literal render as `BLK-VISUAL-RENDER` and recommend a 5-second manual right-click. (Disabling does NOT unregister the package — it stays `Status Ok`; the entry is hidden dynamically.)
|
||||
18. **Shell-extension modules read a module-OWNED settings file, NOT the PT-store `<Module>\settings.json`.** PowerRename, File Locksmith, Image Resizer, and New+ context-menu handlers and exes run *outside* the runner (hosted by Explorer / launched on demand) and cannot use the PT-Settings IPC. Each reads its **own** json in `%LOCALAPPDATA%\Microsoft\PowerToys\<Module>\` *at process/handler launch* (registry-migrated `CSettings`/`Settings` classes — `lib/Settings.cpp` `Load→ParseJson`). The PT-Settings UI writes the *PT-store* `settings.json` (the `bool_*`/`int_*` file `Get-PtModuleSettings` reads); the runner's module DLL syncs PT-store→module-store **only on a Settings-UI change event** — so the PT-store file can be **stale for days** and editing it has **no effect** on the running shell handler. **To drive a settings item on these modules, edit the module-owned file directly (drive-stack §2.A) and relaunch the module (or restart runner+Explorer for the menu handlers), then restore.**
|
||||
|
||||
**Pitfall #18 — module-owned files + their key style** (verified 2026-06-10 against `<PT-repo>\src`):
|
||||
|
||||
| Module | Module-owned file (under `…\PowerToys\<Module>\`) | Key style | PT-store `settings.json` keys (UI/`Get-PtModuleSettings`) |
|
||||
|---|---|---|---|
|
||||
| PowerRename | `power-rename-settings.json` (+ `power-rename-last-run-data.json`, `search-mru.json`, `replace-mru.json`) | `ShowIcon`, `ExtendedContextMenuOnly`, `PersistState`, `MRUEnabled`, `MaxMRUSize`, `UseBoostLib` | `bool_show_icon_on_menu`, `bool_show_extended_menu`, `bool_persist_input`, `bool_mru_enabled`, `int_max_mru_size`, `bool_use_boost_lib` |
|
||||
| File Locksmith | `file-locksmith-settings.json` | `ShowInExtendedContextMenu` | `bool_show_extended_context_menu` |
|
||||
| Image Resizer | `image-resizer-settings.json` | (resize sizes/encoder/etc.) | mirrored `imageresizer*` keys |
|
||||
| New+ | `NewPlus\settings.json` (sub-folder **`NewPlus`**, verified on disk + `constants.h` `powertoy_name=L"NewPlus"`) | `HideFileExtension`, `HideStartingDigits`, `TemplateLocation`, `ReplaceVariables`, `BuiltInNewHidePreference` | mirrored `newplus*` keys |
|
||||
|
||||
Confirm which file actually drives behavior with a quick A/B: edit the module-owned file → relaunch → observe; if behavior follows, that's the source of truth (PowerRename L394/L395/L396/L397/L409 were all driven this way).
|
||||
|
||||
If you find another gap during verification, update this skill (add a recipe) AND consider proposing the addition to references/winapp-ui-testing.md if it's generic enough.
|
||||
143
.github/skills/powertoys-module-verification/references/environment-setup.md
vendored
Normal file
143
.github/skills/powertoys-module-verification/references/environment-setup.md
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
# Environment setup for PowerToys verification
|
||||
|
||||
**Audience**: human user preparing a test machine before running a verification agent.
|
||||
**One-time** (per test session) — restore afterward.
|
||||
|
||||
## Why this matters
|
||||
|
||||
PowerToys release checklists test real user interactions: pressing hotkeys, dragging files, switching windows. Many tests use `SendInput` to inject keystrokes. Windows refuses `SendInput` when the calling session has **no attached input desktop** — and several common Windows states cause exactly that to happen:
|
||||
|
||||
- RDP client minimized
|
||||
- Workstation locked (screensaver kicked in, idle timeout)
|
||||
- Remote machine asleep
|
||||
- Local machine asleep (RDP TCP drops)
|
||||
|
||||
If any of these happens mid-verification, items that need synthetic input fail with `BLK-ENV` even though the feature itself works fine. This guide eliminates the env causes so the only BLOCKED verdicts you see are real test/framework limitations.
|
||||
|
||||
## Per-scenario reference table
|
||||
|
||||
| Scenario | Remote session State | `GetForegroundWindow()` | `SendInput` | Verdict for input-injection tests |
|
||||
|---|---|---|---|---|
|
||||
| mstsc window focused | Active | Real HWND | Works | ✅ Drivable |
|
||||
| mstsc visible but not focused (covered or alt-tabbed) | Active | Real HWND | Works | ✅ Drivable |
|
||||
| **mstsc MINIMIZED** | Active | **0** | **ACCESS_DENIED (5)** | ❌ BLK-ENV |
|
||||
| Local machine sleeps / RDP TCP drops | **Disconnected** | 0 | ACCESS_DENIED | ❌ BLK-ENV |
|
||||
| User closes mstsc with X (no signout) | **Disconnected** | 0 | ACCESS_DENIED | ❌ BLK-ENV |
|
||||
| Sign out from the remote | Session destroyed | — | — | ❌ Agent killed |
|
||||
| Remote machine sleeps | Suspended | — | — | ❌ Catastrophic — timing corruption |
|
||||
| Remote screensaver / auto-lock kicks in | Active but desktop locked | 0 | ACCESS_DENIED | ❌ BLK-ENV |
|
||||
| **2nd RDP login as the SAME user** (you reconnect from another client) | the OLD session flips to **Disconnected** | 0 (in the old session) | ACCESS_DENIED | ❌ BLK-ENV — your running test's session got taken over |
|
||||
|
||||
**Key insight**: "Active" in `quser` ≠ "can inject input". Always check `GetForegroundWindow()` first (the diagnostic script `scripts/pt-session-diagnose.ps1` does this).
|
||||
|
||||
## Can I verify two modules at once in two RDP sessions?
|
||||
|
||||
Short answer on a **client edition of Windows (Windows 10/11, ProductType=1)**: **no — not as the same user, and effectively not at all.** This was investigated live on this machine (Windows 11 Enterprise, build 26200, `fSingleSessionPerUser=1` default):
|
||||
|
||||
- **Two monitors ≠ two sessions.** A multi-monitor setup is **one** session spanning both screens — it shares a single input desktop, foreground window, and `SendInput` queue across the monitors. Monitor count has nothing to do with session count, so "I have two monitors" does not give you two sessions to run two modules in.
|
||||
- **Sessions are isolated** — each Windows session has its own input desktop, its own foreground window, and its own `SendInput` queue. So *typing in session B genuinely does NOT disturb session A's foreground or input.* Cross-session interference is **not** the problem (so if you somehow DID have two live sessions — Server/RDS — they could run in parallel without colliding).
|
||||
- **The real blocker is session takeover.** Client Windows allows only **one interactive (console/owning) session at a time**, and `fSingleSessionPerUser=1` (the default) means one user gets **one** session. When you open the *second* RDP connection (as the same user), Windows **disconnects the first session** — it flips to `Disconnected`, its input desktop detaches, `GetForegroundWindow()` → 0, and any in-flight UI test there fails with `ACCESS_DENIED` → BLK-ENV. It's not your *typing* that breaks the test; it's the act of logging in the second session that evicts the first.
|
||||
- A different *user* account doesn't rescue it either: client Windows still permits only one connected interactive session, so the second login still disconnects the first.
|
||||
- Therefore, on client Windows, **run modules serially in one session.** True concurrent multi-session needs Windows Server + the RDS (Remote Desktop Session Host) role; unofficial multi-session patches exist but are out of scope here.
|
||||
|
||||
> **Verdict on the common assumption "I can run two modules in two RDP sessions because I have two monitors":** the *conclusion* (can't run two at once on client Windows) is correct, but the *reasoning* is wrong on two counts — two monitors is still one session, and you can't get two simultaneously-Active sessions on client Windows at all (the 2nd login disconnects the 1st). The limit is "can't open a 2nd Active session", not "the two sessions fight each other".
|
||||
|
||||
**Practical guidance:** keep a single RDP session for the whole run; don't reconnect/relogin mid-run; if you must check something elsewhere, alt-tab inside the *same* session rather than opening a new RDP connection. To detect a takeover after the fact, `qwinsta` will show your former session as `Disconnected`.
|
||||
|
||||
## Pre-run setup checklist
|
||||
|
||||
Run these BEFORE starting the verification agent.
|
||||
|
||||
### On the test machine (the one being verified)
|
||||
|
||||
```powershell
|
||||
# Snapshot current power settings so you can restore after
|
||||
$bk = "$env:TEMP\powercfg-backup-$(Get-Date -f yyyyMMdd-HHmmss).txt"
|
||||
powercfg /query SCHEME_CURRENT SUB_SLEEP > $bk
|
||||
powercfg /query SCHEME_CURRENT SUB_VIDEO >> $bk
|
||||
"# Restore later with the values from $bk" | Set-Content "$bk.note"
|
||||
|
||||
# Disable sleep + display-off + hibernate (AC and battery)
|
||||
powercfg /change standby-timeout-ac 0
|
||||
powercfg /change standby-timeout-dc 0
|
||||
powercfg /change monitor-timeout-ac 0
|
||||
powercfg /change monitor-timeout-dc 0
|
||||
powercfg /change hibernate-timeout-ac 0
|
||||
powercfg /change hibernate-timeout-dc 0
|
||||
|
||||
# Disable screensaver
|
||||
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '0'
|
||||
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '0'
|
||||
|
||||
# Disable workstation lock-on-idle (requires admin)
|
||||
# 0 = never lock. Restore your original value (commonly 600 = 10 min) afterward.
|
||||
$origLock = (Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System' -Name InactivityTimeoutSecs -EA SilentlyContinue).InactivityTimeoutSecs
|
||||
"$origLock" | Out-File "$bk.lock"
|
||||
Set-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System' -Name InactivityTimeoutSecs -Value 0 -EA SilentlyContinue
|
||||
|
||||
# Confirm
|
||||
powercfg /query SCHEME_CURRENT SUB_SLEEP | Select-String 'Power Setting GUID|Current AC Power Setting Index'
|
||||
```
|
||||
|
||||
### On the local machine (the one with the RDP client)
|
||||
|
||||
```powershell
|
||||
# Disable local sleep so RDP TCP stays alive
|
||||
powercfg /change standby-timeout-ac 0
|
||||
|
||||
# Practical habit: put mstsc on a monitor you're NOT actively working on.
|
||||
# Don't minimize. Alt-tab is fine; minimize is not.
|
||||
```
|
||||
|
||||
## Mid-run discipline
|
||||
|
||||
While the agent is running:
|
||||
- **Don't minimize mstsc.** Visible-but-unfocused is OK; minimized is not.
|
||||
- **Don't close mstsc with the X.** If you have to step away, fine — leave it open.
|
||||
- **Don't disconnect or reconnect RDP.** Stay continuously connected for the duration of the run.
|
||||
- **Don't sign out** on either end.
|
||||
- If you do step away and the screen locks (despite the setup above), reconnect/unlock and the agent's `Test-PtSessionStillInteractive` guard (if used) will resume; otherwise items mid-execution will be BLK-ENV.
|
||||
|
||||
## Post-run cleanup (restore)
|
||||
|
||||
```powershell
|
||||
# Restore the values you captured to $bk before starting
|
||||
# (e.g. typical defaults: standby 30min, monitor 15min, screensaver 600s, lock 600s)
|
||||
powercfg /change standby-timeout-ac 30
|
||||
powercfg /change standby-timeout-dc 15
|
||||
powercfg /change monitor-timeout-ac 15
|
||||
powercfg /change monitor-timeout-dc 10
|
||||
powercfg /change hibernate-timeout-ac 0 # often default
|
||||
|
||||
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '1'
|
||||
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '600'
|
||||
|
||||
$origLock = Get-Content "$bk.lock" -EA SilentlyContinue
|
||||
if ($origLock) {
|
||||
Set-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System' `
|
||||
-Name InactivityTimeoutSecs -Value ([int]$origLock) -EA SilentlyContinue
|
||||
}
|
||||
```
|
||||
|
||||
(Values above are typical; adjust to your environment policy.)
|
||||
|
||||
## Diagnostic before you start
|
||||
|
||||
Run `scripts/pt-session-diagnose.ps1` from the agent shell. Expected output for a GO:
|
||||
|
||||
```
|
||||
PASS - this shell can drive interactive PowerToys tests.
|
||||
```
|
||||
|
||||
If it prints FAIL with a `psexec -i <consoleSession> -s pwsh.exe` hint, you're in a non-console session — relaunch the agent shell as suggested before starting verification.
|
||||
|
||||
## Why this isn't in the global SKILL.md
|
||||
|
||||
These are **human prep steps**, not agent instructions. The agent needs to *detect* a bad environment (via `Test-PtInteractiveDesktop` in pre-flight + `Test-PtSessionStillInteractive` mid-run); the user needs to *prevent* one. Different audiences, different docs.
|
||||
|
||||
## Related
|
||||
|
||||
- `scripts/pt-session-diagnose.ps1` — one-shot session diagnostic
|
||||
- `scripts/pt-foreground-guard.ps1` — `Test-PtForeground` / `Force-PtForeground` / `Assert-PtForegroundOrAbort` used by agent
|
||||
- `SKILL.md` pitfall #13 — short pointer to this doc
|
||||
- `references/pre-flight.md` pre-flight check #4 — agent reads this doc when it detects a bad env
|
||||
99
.github/skills/powertoys-module-verification/references/explorer-context-menu-flow.md
vendored
Normal file
99
.github/skills/powertoys-module-verification/references/explorer-context-menu-flow.md
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
# Explorer context-menu flow — driving PowerToys shell-menu modules end-to-end
|
||||
|
||||
**Audience**: agents verifying any PowerToys module whose entry point is the **Windows Explorer right-click context menu** — i.e. **File Locksmith, Image Resizer, PowerRename, New+ (NewPlus)**, and similar.
|
||||
|
||||
This is the *true user flow*: open Explorer → select file(s) → right-click → click the module's menu item. Use it when an item's assertion is specifically about the **context menu** (e.g. "the entry appears / no longer appears", "right-click → X launches the module on the selection"). For the module's *internal* behavior you can still prefer a faster back-door (CLI / `last-run.log` / Named Event) — see each module profile — but the menu presence/launch itself can only be observed this way.
|
||||
|
||||
Helper: `scripts/pt-explorer-contextmenu.ps1` (`Test-PtDesktopInteractive`, `Open-PtExplorerContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems`).
|
||||
|
||||
## Which approach first? (CLI / back-door vs synthetic menu)
|
||||
|
||||
**Pick the tool by what the item ASSERTS — not "always synthetic" or "always CLI".**
|
||||
|
||||
| The item asserts… | First approach | Why |
|
||||
|---|---|---|
|
||||
| **The menu itself** — entry *appears / no longer appears*, "right-click → select X", caption / localization of the entry | **Synthetic Explorer menu (this doc)** — the *only* valid observer | The CLI/back-door is **blind to the menu**: it runs even when the entry is correctly hidden, so it gives a false PASS (the L652 trap). If the desktop is locked → `BLK-ENV`; do **not** substitute the CLI. |
|
||||
| **Module behavior** — engine finds the lockers, images get resized, files get renamed (the menu is just the trigger) | **CLI / back-door** (`FileLocksmithCLI.exe`, `last-run.log`, Named Event, DSC) | Instant, deterministic, foreground-free, works on a locked desktop. Synthetic adds ~10s + foreground/retry fragility without changing the assertion. |
|
||||
|
||||
**Golden-path rule (do once per module):** run **one** full synthetic right-click → invoke-the-item → confirm-launch. That proves the menu→launch wiring is actually registered *and* validates that the fast back-door is behaviorally equivalent to the real menu (e.g. File Locksmith L641 `step-04/05` did exactly this). After that one golden run, trust the back-door for the remaining behavior items.
|
||||
|
||||
Net: for a context-menu module, **most items are behavior → CLI-first**; the **menu-presence/absence/launch/localization items → synthetic-first**; plus one golden-path synthetic launch.
|
||||
|
||||
## Is it stable?
|
||||
|
||||
**Yes — with the robust variant below.** Verified repeatedly on Win11 (2026-06-08) launching File Locksmith via a genuine right-click + menu click. Two rules make it reliable; ignore them and it gets flaky:
|
||||
|
||||
1. **Invoke the menu item by UIA InvokePattern, not a coordinate left-click.** The menu item exposes `InvokePattern` (`isInvokable=True`). `winapp ui invoke <selector> -w <menuHwnd>` is robust and needs no foreground/coordinates for the *click*. A synthetic left-click at the item's pixel center also works but is the fragile part (DPI, menu repositioning near screen edges, scrolled menus).
|
||||
2. **The right-click that OPENS the menu still needs synthetic input on a foregrounded window — and occasionally a retry.** The first right-click right after Explorer opens sometimes misses (foreground not settled). `Open-PtExplorerContextMenu` retries up to 3×; that removed the flakiness in testing.
|
||||
|
||||
**Hard prerequisite — unlocked interactive desktop.** Synthetic right-click injects into the session input stream, so it requires foreground. If the workstation is locked / RDP minimized (`GetForegroundWindow()=0`), this flow is `BLK-ENV` — there is no foreground-free way to open a context menu. `Open-PtExplorerContextMenu` throws a clear BLK-ENV error in that case. (A 4-hour idle auto-lock is the common culprit — see `references/environment-setup.md`.)
|
||||
|
||||
**Other constraints:**
|
||||
- **Settings for these modules live in a module-OWNED file, not the PT-store `settings.json`** — see `SKILL.md` pitfall #18. The context-menu handler reads e.g. `power-rename-settings.json` / `file-locksmith-settings.json` / `image-resizer-settings.json` / `New\settings.json` at launch; editing the PT-store `<Module>\settings.json` (what `Get-PtModuleSettings` reads) often has **no effect** on the live handler. Drive icon/extended-menu/feature toggles via the module-owned file + relaunch (restart runner+Explorer for the menu handlers), then restore.
|
||||
- This is the **Win11 packaged** context menu (`Microsoft.UI.Content.PopupWindowSiteBridge` / "PopupHost"). The packaged module commands appear **only** here — not in classic `Shell.Application.Verbs()` and not via `CoCreate` of the command CLSID (`REGDB_E_CLASSNOTREGISTERED`). On Win10, or under "Show more options", you'd get the classic menu instead (different structure).
|
||||
- The menu exists in the UIA tree **only while open** — you must open it with real input first; you can't enumerate it cold.
|
||||
- A menu-launched module UI runs **non-elevated** (Explorer's integrity), even if your agent shell is elevated. Mind elevation-visibility (e.g. a non-elevated File Locksmith can't see higher-IL processes — match locker integrity with `scripts/pt-nonelevated.ps1`).
|
||||
|
||||
## Recipe (robust)
|
||||
|
||||
```powershell
|
||||
. "$skill\scripts\pt-explorer-contextmenu.ps1"
|
||||
|
||||
# 0) Guard: must be an unlocked desktop
|
||||
if (-not (Test-PtDesktopInteractive)) { <# mark BLK-ENV, cite references/environment-setup.md #> }
|
||||
|
||||
# 1) Open Explorer on the target folder and grab its CabinetWClass HWND
|
||||
Start-Process explorer.exe $dir; Start-Sleep 4
|
||||
$hwnd = (winapp ui list-windows --json | ConvertFrom-Json |
|
||||
Where-Object { $_.className -eq 'CabinetWClass' -and $_.title -match [regex]::Escape((Split-Path $dir -Leaf)) } |
|
||||
Select-Object -First 1).hwnd
|
||||
|
||||
# 2) Open the real context menu (synthetic right-click, auto-retry)
|
||||
$menu = Open-PtExplorerContextMenu -ExplorerHwnd $hwnd -FileName 'target.txt'
|
||||
|
||||
# 3a) ASSERT PRESENCE / ABSENCE (e.g. "entry no longer appears" when the module is disabled)
|
||||
$items = Get-PtContextMenuItems -MenuHwnd $menu # all visible MenuItem names
|
||||
$present = $items -contains 'Unlock with File Locksmith'
|
||||
|
||||
# 3b) LAUNCH the module via the real menu (UIA invoke by NAME — robust)
|
||||
$ok = Invoke-PtContextMenuItem -MenuHwnd $menu -ItemName 'Unlock with File Locksmith'
|
||||
|
||||
# 4) Verify the module launched (its process/window appears) — e.g.:
|
||||
Start-Sleep 4
|
||||
$ui = Get-Process PowerToys.FileLocksmithUI -EA SilentlyContinue # or PowerToys.ImageResizer, PowerToys.PowerRename
|
||||
```
|
||||
|
||||
To **assert absence** after disabling a module: re-open the menu and check `Get-PtContextMenuItems` no longer contains the caption (the packaged `GetState` re-reads the enabled flag live, so no Explorer restart is needed between toggles).
|
||||
|
||||
## Multi-file selection (Image Resizer, PowerRename)
|
||||
|
||||
These operate on a **selection** of files. Select first (Shell COM is reliable and foreground-free), then right-click one of the selected items:
|
||||
- Use `scripts/pt-explorer-com.ps1` → `Open-PtExplorerAtPath` + `Select-PtExplorerFiles` to establish the multi-select.
|
||||
- Then `Open-PtExplorerContextMenu` on one selected file and `Invoke-PtContextMenuItem` — the module receives the whole selection (the shell handler enumerates all selected `IShellItem`s).
|
||||
|
||||
## Module captions (match by NAME)
|
||||
|
||||
Match the **visible caption**, not the AutomationId (Explorer assigns per-session numeric IDs like `32012` whose value/order varies). Discover the exact caption at runtime with `Get-PtContextMenuItems`. Verified captions:
|
||||
|
||||
| Module | Launched process | Menu caption (verified ✓ / expected) |
|
||||
|---|---|---|
|
||||
| File Locksmith | `PowerToys.FileLocksmithUI.exe` | ✓ `Unlock with File Locksmith` (NB: **not** the checklist's "What's using this file?") |
|
||||
| PowerRename | `PowerToys.PowerRename.exe` | ✓ `Rename with PowerRename` |
|
||||
| Image Resizer | `PowerToys.ImageResizer.exe` | `Resize images` (verify via `Get-PtContextMenuItems` — caption shifted across versions) |
|
||||
| New+ | (creates from template) | `New+` (submenu) |
|
||||
|
||||
> Tip: if a module's caption is unknown, enable the module, open the menu on an applicable file, and run `Get-PtContextMenuItems` to read the exact string — then hard-match it for present/absent assertions.
|
||||
|
||||
## Common failure modes → fixes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `BLK-ENV: ... GetForegroundWindow()=0` | desktop locked / RDP minimized | unlock & keep mstsc un-minimized (`references/environment-setup.md`); mark `BLK-ENV`, not a test failure |
|
||||
| "popup not found after N attempts" | foreground not settled (esp. first right-click after Explorer opens) | the helper already retries 3×; raise `-MaxTries`, or pre-foreground the window once before calling |
|
||||
| menu item `invoke` returns but nothing launches | matched the wrong node / item disabled | match `type -eq 'MenuItem'` by exact Name; confirm the module is enabled |
|
||||
| caption not found though module enabled | wrong/old caption string, or it's under "Show more options" (classic menu) | enumerate with `Get-PtContextMenuItems`; for classic menu invoke `expandtoclassic` first |
|
||||
| launched UI shows nothing | menu-launched UI is non-elevated and can't see higher-IL targets | match target integrity (`scripts/pt-nonelevated.ps1`) |
|
||||
|
||||
## Referenced by
|
||||
- `references/modules/file-locksmith.md` (L641/L652 — real right-click launch + menu present/absent)
|
||||
- *(future)* `references/modules/image-resizer.md`, `references/modules/power-rename.md`, `references/modules/new-plus.md` — reference this doc for their context-menu items.
|
||||
116
.github/skills/powertoys-module-verification/references/modules/README.md
vendored
Normal file
116
.github/skills/powertoys-module-verification/references/modules/README.md
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
# Per-module verification profiles (`references/modules/`)
|
||||
|
||||
This folder holds **one short profile per PowerToys module**. Each profile is self-contained guidance specific to that module — paths, entry-paths, capability/control recipes, common BLOCKED traps, fixture lists, source citations.
|
||||
|
||||
## When to read
|
||||
|
||||
When this skill runs for a specific module, check whether `references/modules/<module>.md` exists here. If yes: **read it BEFORE walking the SKILL.md drive-stack** — it tells you which entry-paths actually work for this module's quirks and which BLOCKED traps to avoid.
|
||||
|
||||
If no profile exists, fall back to SKILL.md + the helper scripts.
|
||||
|
||||
## Shared cross-module flows
|
||||
|
||||
Some flows are common to several modules and live in their own top-level docs (not per-module):
|
||||
- **`../references/explorer-context-menu-flow.md`** — driving the real Win11 Explorer right-click context menu end-to-end (open + assert present/absent + launch). Referenced by File Locksmith and any future **Image Resizer / PowerRename / New+** profiles.
|
||||
|
||||
## Why per-module (not just one big SKILL.md)
|
||||
|
||||
- Each module has its own quirks (Peek's `_isFromCli` guard, CmdPal's TextChanged-broken state, PT Run's mini-popup HWND, Workspaces' snapshot-elevation rules). Bundling all of them into the global SKILL.md bloats context and forces every verification to load 25+ KB of mostly-irrelevant text.
|
||||
- A profile lets a focused verification run with only the relevant 5-10 KB.
|
||||
- New gotchas discovered during a module verification round get added to that module's profile, not the global one — keeps the global doc stable.
|
||||
|
||||
## Profile catalog
|
||||
|
||||
| Module | Profile | Status |
|
||||
|---|---|---|
|
||||
| Peek | `peek.md` | ✅ written 2026-06-08 |
|
||||
| File Locksmith | `file-locksmith.md` | ✅ written 2026-06-08 |
|
||||
| Image Resizer | `image-resizer.md` | ✅ written 2026-06-09 |
|
||||
| PowerRename | `power-rename.md` | ✅ written 2026-06-10 (first to cite `../context-menu-cookbook.md` for shared mechanics) |
|
||||
| New+ | `new-plus.md` | ✅ written 2026-06-18 (registration-gate for menu presence; Settings-UI toggle drives template auto-copy) |
|
||||
| (other modules to be added as we encounter sign-off needs) | — | — |
|
||||
|
||||
## For Explorer-context-menu modules: read the canonical flow doc first
|
||||
|
||||
If you're writing a profile for a module that registers an entry in Explorer's Win11 right-click menu (PowerRename, File Locksmith, Image Resizer, New+, Preview Pane, RegistryPreview), **read `../references/explorer-context-menu-flow.md` first**. It has the canonical synthetic-right-click + UIA-invoke recipe with:
|
||||
|
||||
- Which-approach-first decision rule (CLI back-door vs synthetic menu, with the false-positive trap warning)
|
||||
- Stability rules (UIA InvokePattern, retry on first right-click)
|
||||
- Recipe (robust 5-step flow)
|
||||
- Multi-file selection notes
|
||||
- Module captions table (per-module menu-item display names)
|
||||
- Common failure modes
|
||||
- The unlocked-desktop requirement (BLK-ENV gating)
|
||||
|
||||
The shared helper is `scripts/pt-explorer-contextmenu.ps1` (`Test-PtDesktopInteractive`, `Open-PtExplorerContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems`).
|
||||
|
||||
Your module profile then only documents the **module-specific** quirks: settings.json schema keys, expected verb caption regex, capability/control recipes, source citations, ceiling.
|
||||
|
||||
`power-rename.md` is the model — ~9 KB despite covering 18 items because the generic mechanics live in the canonical flow doc.
|
||||
|
||||
## Profile template
|
||||
|
||||
When writing a new profile, use this skeleton:
|
||||
|
||||
```markdown
|
||||
# <Module> — module verification profile
|
||||
|
||||
**PT module**: `<ModuleKey>` (one-line description)
|
||||
**Source**: `<PT-repo>\src\modules\<dir>\` (PT repo)
|
||||
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\<dir>\settings.json`
|
||||
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\<dir>\Logs\v<ver>\log_<date>.log`
|
||||
**Exes**: `<full path>`
|
||||
**Default hotkey**: `<keys>` (modifiers + code, plus path to ActivationShortcut in settings)
|
||||
**Named Event**: `Local\<name>` (friendly name in pt-shared-events.ps1 catalog)
|
||||
**DSC resource**: `Microsoft.PowerToys/<Name>Settings`
|
||||
|
||||
## Entry-paths (try in order)
|
||||
|
||||
### 1. <fastest path>
|
||||
<powershell code + when to use + source citation>
|
||||
|
||||
### 2. <alternate path>
|
||||
<...>
|
||||
|
||||
### 3. <last-resort path>
|
||||
<...>
|
||||
|
||||
## Recipes — a control/observation map, NOT a per-test-case answer key
|
||||
|
||||
| # | Capability | Drive (control / settings key) | Observe (where the result shows) |
|
||||
|---|---|---|---|
|
||||
| 1 | <a module capability, e.g. "context-menu entry present when enabled"> | <which AutomationId / control / settings key drives it> | <where the result is visible: preview column, settings.json, disk, log, menu> |
|
||||
| 2 | <next capability> | <...> | <...> |
|
||||
|
||||
> **Mapping process** (agent at runtime): read the actual checklist item → identify the capability → find its row → drive the named control and **design your own inputs + assertions for that item**. If no row matches, it's a NEW capability — drive ad-hoc and add a row (capability + control + observation point; no canned inputs).
|
||||
|
||||
> **Why a map, not an answer key**: the table must carry only **durable module knowledge** — which control drives a capability and where to observe the result. Concrete Search/Replace inputs and expected-output assertions are *per-test-case answers*; baking them in turns the profile into a cheat sheet that (a) lets the agent copy answers without understanding and (b) goes stale the moment a checklist item changes its wording or values. Keep inputs + assertions OUT. Only a real UI redesign (a renamed/moved/removed control) should force an edit to this table.
|
||||
|
||||
## Common BLOCKED traps
|
||||
|
||||
<list of mistakes prior agents made + how to avoid them>
|
||||
|
||||
## Fixture files needed
|
||||
|
||||
<list of pre-canned files the verification expects>
|
||||
|
||||
## Source citations
|
||||
|
||||
<paths in PT repo that explain module behavior or guards>
|
||||
|
||||
## Ceiling
|
||||
|
||||
<observed PASS rate / total>
|
||||
|
||||
## Don'ts
|
||||
|
||||
<list of common mistakes>
|
||||
```
|
||||
|
||||
## Hygiene
|
||||
|
||||
- **Keep each profile under ~10 KB.** If it grows beyond that, the module has too many quirks — escalate to maintainer review of the upstream checklist.
|
||||
- **The recipe table is a control/observation MAP, not an answer key.** Columns are *Capability → Drive (control/key) → Observe*. **Do NOT bake in concrete Search/Replace inputs or expected-output assertions** — those are per-test-case answers that go stale when a checklist item changes and let the agent copy without understanding. The agent designs inputs + assertions at runtime from the actual checklist item.
|
||||
- **Tables are capability-keyed, NOT line-keyed.** Upstream checklist line numbers (`L<n>`) **must not appear** in the profile — they drift between releases (items added/removed/reordered) and turn the table into a silent mismatch trap. PT-source-code file:line citations (e.g. `dllmain.cpp:73`) ARE allowed; they're version-pinned and serve a different purpose.
|
||||
- **Cite source-code line numbers** where module behavior surprises (e.g. CLI guards, debounce timings, fallback chains). Reviewers can verify your claims by reading those lines.
|
||||
- **Update the profile after every verification round**; promote any new technique into the right helper script if it generalizes beyond this module.
|
||||
103
.github/skills/powertoys-module-verification/references/modules/file-locksmith.md
vendored
Normal file
103
.github/skills/powertoys-module-verification/references/modules/file-locksmith.md
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
# File Locksmith — module verification profile
|
||||
|
||||
**PT module**: `File Locksmith` (shows which processes are using selected files/dirs and lets you kill them)
|
||||
**Source**: `<PT-repo>\src\modules\FileLocksmith\` (PT repo)
|
||||
**Settings file (module)**: `%LOCALAPPDATA%\Microsoft\PowerToys\File Locksmith\settings.json` (`{"properties":{"bool_show_extended_menu":{...}}}`) and `file-locksmith-settings.json` (`{"showInExtendedContextMenu":bool}`)
|
||||
**Enable flag**: `%LOCALAPPDATA%\Microsoft\PowerToys\settings.json` → `enabled."File Locksmith"` (general settings; runner-owned)
|
||||
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\File Locksmith\FileLocksmithUI\Logs\…`
|
||||
**Exes**: UI = `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.FileLocksmithUI.exe`; CLI = `%LOCALAPPDATA%\PowerToys\FileLocksmithCLI.exe`
|
||||
**Context menu**: Win11 packaged `IExplorerCommand` CLSID `{AAF1E27D-4976-49C2-8895-AAFA743C0A7E}` (sparse pkg `Microsoft.PowerToys.FileLocksmithContextMenu`); legacy `FileLocksmithExt.dll`. Caption resource = "What's using this file?".
|
||||
**Named Event / DSC**: no Named Event. DSC resource `microsoft.powertoys.FileLocksmith.settings` exists (controls module settings, not the master enable flag).
|
||||
**No global hotkey** — entry is the Explorer context menu only.
|
||||
|
||||
## The two back-doors that make this module fully drivable (no Explorer needed)
|
||||
|
||||
### 1. `FileLocksmithCLI.exe` — deterministic engine ground-truth (PREFER for assertions)
|
||||
Accepts paths as args; `--json`, `--kill`, `--wait`, `--timeout`. Uses the **same** `find_processes_recursive` engine as the UI.
|
||||
```powershell
|
||||
$cli = "$env:LOCALAPPDATA\PowerToys\FileLocksmithCLI.exe"
|
||||
& $cli "<file|dir|drive>" --json | ConvertFrom-Json # {processes:[{pid,name,user,files[]}]}
|
||||
& $cli "<path>" --kill # terminate lockers (== End task)
|
||||
```
|
||||
Detection = open **File handles** + **loaded modules** under the path (exact file, exact dir, dir-prefix recursive). `FileLocksmith.cpp:18-113`.
|
||||
|
||||
### 2. `last-run.log` IPC + launch UI — exercises the REAL UI code path
|
||||
The context-menu handler writes selected paths to `…\File Locksmith\last-run.log` then launches the UI; the UI reads them in `MainViewModel()` ctor. Reproduce it:
|
||||
```powershell
|
||||
# UTF-16LE, each path + WCHAR \n, trailing empty-line terminator, NO BOM
|
||||
function Write-LastRun([string[]]$Paths){
|
||||
$f="$env:LOCALAPPDATA\Microsoft\PowerToys\File Locksmith\last-run.log"; $ms=[IO.MemoryStream]::new()
|
||||
foreach($p in $Paths){$b=[Text.Encoding]::Unicode.GetBytes($p);$ms.Write($b,0,$b.Length);$n=[Text.Encoding]::Unicode.GetBytes("`n");$ms.Write($n,0,$n.Length)}
|
||||
$n=[Text.Encoding]::Unicode.GetBytes("`n");$ms.Write($n,0,$n.Length);[IO.File]::WriteAllBytes($f,$ms.ToArray())
|
||||
}
|
||||
Write-LastRun @("C:\path\to\file"); Start-Process "$env:LOCALAPPDATA\PowerToys\WinUI3Apps\PowerToys.FileLocksmithUI.exe"
|
||||
```
|
||||
Source: `ExplorerCommand.cpp:182-227`, `dllmain.cpp:94-159`, `IPC.cpp`, `NativeMethods.cpp:62-97`.
|
||||
|
||||
### UI selectors (winapp ui)
|
||||
- Window title: `Administrator: File Locksmith` (elevated) vs `File Locksmith` (non-elevated).
|
||||
- Header button = the path label (`btn-<pathname>-…`); top-right `btn-…` = **Reload** (tooltip "Reload").
|
||||
- Per-row End task button = `btn-…` (parent of `lbl-endtask-…`); invoke it (`InvokePattern`).
|
||||
- `RestartAsAdminBtn` = shield icon, **visible only when non-elevated** (`MainPage.xaml:72-82`).
|
||||
- `ProcessesListView` is virtualized; use `winapp ui scroll ProcessesListView --direction down/up`.
|
||||
|
||||
## Recipes — a control/observation map, NOT a per-test-case answer key
|
||||
|
||||
> Maps each capability to **how to drive it** and **where the result shows**. No canned process counts / paths / assertions — design those at runtime from the actual checklist item.
|
||||
|
||||
| # | Capability | Drive (entry-path / control) | Observe (where the result shows) |
|
||||
|---|---|---|---|
|
||||
| 1 | A locked file lists all its locking processes | CLI + UI on one locked file (with multiple lockers) | each locker shows as a ListItem |
|
||||
| 2 | "End task" kills the locker and de-lists it | `winapp ui invoke` the End-task button | locker PID dies + row removed |
|
||||
| 3 | Reload rediscovers a locker started after the UI opened | start a new locker → invoke Reload | the new locker appears |
|
||||
| 4 | Closing a locker externally auto-removes it | external `Stop-Process` on a locker | auto-delisted via `WatchProcess`; empty state shown |
|
||||
| 5 | Directory path finds lockers recursively | CLI/UI with a directory path | lockers inside the tree are listed |
|
||||
| 6 | Drive root lists many lockers without crashing | CLI/UI with a drive root | large list renders; no crash |
|
||||
| 7 | Non-elevated FL does NOT see the elevated runner | run CLI/UI non-elevated via scheduled task `RunLevel Limited` | `PowerToys.exe` absent (medium-IL FL can't see elevated procs) |
|
||||
| 8 | "Restart as administrator" surfaces elevated-only lockers | non-elev UI shows the button; elevated run shows them | elevated run lists `PowerToys.exe` (UAC consent click NOT automatable) |
|
||||
| 9 | Scrolling a large list doesn't crash | UI on a drive root + `winapp ui scroll` | process alive + responsive after scroll |
|
||||
| 10 | Disabling FL removes the Explorer context-menu entry | Settings toggle Off (winapp ui) | `enabled."File Locksmith"`→false; `GetState→ECS_HIDDEN` (source) |
|
||||
|
||||
> **Mapping process**: read the actual checklist item → identify the capability → find its row → drive the named control and design your own inputs + assertions. If no row matches, drive ad-hoc and add a row (capability + control + observation point; no canned inputs).
|
||||
|
||||
## Common BLOCKED traps (avoid)
|
||||
- **Don't BLOCK the lock-detection / End-task / scroll-doesn't-crash items as "needs a real installer / right-click"** — both the CLI and the `last-run.log`+UI back-door fully drive them with any locked file. The context menu literally just writes `last-run.log` + launches the same exe.
|
||||
- **Launching the UI from an elevated shell makes it elevated** (title "Administrator: …") and hides the `RestartAsAdminBtn`. To test the non-elevated case (Recipes 7-8), launch via a **scheduled task with `-RunLevel Limited` / LogonType Interactive** — it lands on the user's desktop at medium IL.
|
||||
- **`set-value` does fire the Reload** here (TogglePattern/Invoke work); no TextChanged gotchas.
|
||||
|
||||
## Elevation semantics (non-elevated FL invisibility — Recipes 7-8 core)
|
||||
A medium-IL File Locksmith can't `DuplicateHandle`/read modules of higher-integrity processes, so the **elevated PowerToys.exe runner is invisible** to a non-elevated FL and **visible** to an elevated FL (which also calls `SetDebugPrivilege`, `App.xaml.cs:53-61`). Per-user installs put PowerToys under `%LOCALAPPDATA%\PowerToys`, not "Program Files" — use the PT install dir as the stand-in and note the caveat.
|
||||
|
||||
## Context-menu disable gate (Recipe 10)
|
||||
`GetEnabled()` reads general `enabled."File Locksmith"` (`Settings.cpp:53-77`). When false: Win11 `GetState→ECS_HIDDEN` (`dllmain.cpp:81-84`); legacy `QueryContextMenu→E_FAIL` (`ExplorerCommand.cpp:116-119`). Disabling does NOT unregister the sparse package (stays `Status Ok`) — the entry is hidden dynamically. The packaged `IExplorerCommand` is **not** enumerable via Shell `Verbs()` and **not** `CoCreate`-able from a non-Explorer host (`REGDB_E_CLASSNOTREGISTERED`), so the pixel-level render is the only un-automatable bit (`BLK-VISUAL-RENDER` if you need it).
|
||||
|
||||
## Fixture files needed
|
||||
None pre-canned. Create a temp file and lock it with a helper process (pwsh holding `File.Open(path, OpenOrCreate, Read, ReadWrite)` so multiple lockers coexist).
|
||||
|
||||
## Source citations
|
||||
- `FileLocksmithLibInterop\FileLocksmith.cpp:18-113` — `find_processes_recursive` (handles + modules, recursive).
|
||||
- `FileLocksmithLibInterop\NativeMethods.cpp:62-140` — `ReadPathsFromFile`, `StartAsElevated` (runas/--elevated).
|
||||
- `FileLocksmithLib\IPC.cpp`, `Constants.h` — last-run.log format/path.
|
||||
- `FileLocksmithUI\…\MainViewModel.cs:80-183` — load/EndTask/WatchProcess/RestartElevated.
|
||||
- `FileLocksmithUI\…\MainPage.xaml` — control layout + RestartAsAdminBtn visibility.
|
||||
- `FileLocksmithExt\ExplorerCommand.cpp`, `FileLocksmithContextMenu\dllmain.cpp`, `FileLocksmithLib\Settings.cpp` — enable gate.
|
||||
|
||||
## Ceiling
|
||||
10/10 PASS observed (2026-06-08). The lock-detection / End-task / refresh / drive-scroll items cleanly driven; the Restart-as-admin item PASS-with-caveat (UAC consent click not automatable; outcome verified). **The disable-removes-menu item PASS — behaviorally verified** by a real Explorer right-click: with FL enabled the Win11 menu shows `MenuItem "Unlock with File Locksmith"`; after disabling in Settings the same right-click menu no longer shows it (no Explorer restart needed — `GetState` re-reads `enabled."File Locksmith"` live). NB the **shipped caption is "Unlock with File Locksmith"**, not the checklist's "What's using this file?". The right-click test needs an **unlocked interactive desktop** (a 4-hour idle auto-lock makes `GetForegroundWindow()=0` → `BLK-ENV`).
|
||||
|
||||
## Real right-click verification (Recipe 10) — works on an unlocked desktop
|
||||
**Use the shared flow: `references/explorer-context-menu-flow.md` + `scripts/pt-explorer-contextmenu.ps1`.** FL's caption is **"Unlock with File Locksmith"**. Quick version:
|
||||
```powershell
|
||||
. "$skill\scripts\pt-explorer-contextmenu.ps1"
|
||||
$menu = Open-PtExplorerContextMenu -ExplorerHwnd $hwnd -FileName 'target.txt' # synthetic right-click (+retry)
|
||||
# present/absent assertion:
|
||||
(Get-PtContextMenuItems -MenuHwnd $menu) -contains 'Unlock with File Locksmith' # true enabled / false disabled
|
||||
# real launch (UIA invoke by name):
|
||||
Invoke-PtContextMenuItem -MenuHwnd $menu -ItemName 'Unlock with File Locksmith' # -> launches PowerToys.FileLocksmithUI.exe (non-elevated)
|
||||
# Toggle FL off in Settings, re-open menu, assert the caption is gone. No Explorer restart needed (GetState re-reads live).
|
||||
```
|
||||
|
||||
## Don'ts
|
||||
- Don't expect `Shell.Application.Verbs()` to show the FL entry — it's a Win11 packaged command, invisible to classic verbs.
|
||||
- Don't kill processes by name; use `Stop-Process -Id <pid>`.
|
||||
- Don't forget to restore `enabled."File Locksmith"=true` and close test-spawned UI/Settings after the disable-removes-menu test.
|
||||
87
.github/skills/powertoys-module-verification/references/modules/image-resizer.md
vendored
Normal file
87
.github/skills/powertoys-module-verification/references/modules/image-resizer.md
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
# Image Resizer — module verification profile
|
||||
|
||||
**PT module**: `Image Resizer` (resize images via Explorer right-click; WinUI 3 GUI + headless CLI)
|
||||
**Source**: `<PT-repo>\src\modules\imageresizer\` (PT repo)
|
||||
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\Image Resizer\settings.json` (PowerToys-wrapper shape: `{ "properties": { "imageresizer_*": { "value": … } }, "name": "Image Resizer", "version": "1" }`). A legacy `sizes.json` mirrors `imageresizer_sizes`; `image-resizer-settings.json` is `{}` (unused).
|
||||
**Enable flag**: `%LOCALAPPDATA%\Microsoft\PowerToys\settings.json` → `enabled."Image Resizer"` (runner-owned; restart runner after toggling).
|
||||
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\Image Resizer\Logs\…`
|
||||
**Exes**: GUI = `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.ImageResizer.exe`; **CLI = `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.ImageResizerCLI.exe`**.
|
||||
**Context menu**: Win11 packaged `IExplorerCommand` (sparse pkg `ImageResizerContextMenuPackage.msix`, dllmain.cpp) + legacy classic `ImageResizerExt.dll` (`dll/ContextMenuHandler.cpp`). **Shipped caption = "Resize with Image Resizer"** (`IDS_IMAGERESIZER_CONTEXT_MENU_ENTRY`; checklist's "Resize images" is STALE).
|
||||
**No global hotkey / no Named Event / no DSC for the engine** — entry is the Explorer menu (or direct exe launch).
|
||||
|
||||
## The back-door that makes this module ~fully drivable (no Explorer needed)
|
||||
|
||||
### `PowerToys.ImageResizerCLI.exe` — the deterministic engine (PREFER for all resize-behavior items)
|
||||
Shares the exact `ResizeBatch.FromCliOptions` → `ResizeBatch.ProcessAsync` → `ResizeOperation.ExecuteAsync` engine as the GUI (`ui/Cli/ImageResizerCliExecutor.cs:76-108`, `App.xaml.cs:102`). Reads live `Image Resizer\settings.json` then applies CLI overrides (`CliSettingsApplier.cs`).
|
||||
```
|
||||
--width/-w --height/-h --unit/-u {Centimeter|Inch|Percent|Pixel} --fit/-f {Fill|Fit|Stretch}
|
||||
--size <presetIndex> --shrink-only --replace/-r --ignore-orientation --remove-metadata
|
||||
--quality/-q --keep-date-modified --filename/-n "<%1..%6>" --destination/-d <dir>
|
||||
--show-config --help <files…> (also accepts \\.\pipe\<name> and stdin file list)
|
||||
```
|
||||
`--show-config` dumps the effective settings (great pre/post check). `-d <dir>` keeps outputs isolated. Assert output dimensions with `[System.Drawing.Image]::FromFile(p)`.
|
||||
**Caveat**: `--ignore-orientation`/`--shrink-only`/`--replace`/`--keep-date-modified`/`--remove-metadata` are *flags* — they can only set the value **true**; to test the **false** case, temporarily edit `settings.json` (back up + restore).
|
||||
|
||||
### Direct GUI launch — for the two UI-only items (gif warning, size-list populated)
|
||||
`Start-Process PowerToys.ImageResizer.exe "<file>"` opens the window pre-loaded (argv/stdin via `ResizeBatch.FromCommandLine`). Behaviorally identical to the context-menu launch (`dllmain.cpp:219-245` just writes a pipe + launches the same exe). Then drive with `winapp ui`:
|
||||
- Size selector: `SizeComboBox` → `winapp ui invoke SizeComboBox -w <hwnd>` to expand, then `inspect` shows `itm-<name>-XXXX` ListItems.
|
||||
- Gif warning: `Message Text "Gif files with animations may not be correctly resized."` InfoBar, bound to `ViewModel.HasGifFiles` (set when any file ends `.gif`).
|
||||
|
||||
## Engine facts (verified from source — cite these for the resize items)
|
||||
- `ResizeFit`: **Fill=0, Fit=1, Stretch=2** (`ResizeFit.cs`). Fit=`min(scaleX,scaleY)`; Fill=`max`+centered-crop; Stretch=independent (`ResizeOperation.cs:449-498`).
|
||||
- `ResizeUnit`: **Centimeter=0, Inch=1, Percent=2, Pixel=3** (`ResizeUnit.cs`). Inch=`v*dpi`; cm=`v*dpi/2.54`; Percent=`v/100*orig`; Pixel=`v` (`ResizeSize.cs:109-123`). **Outputs depend on image DPI** — read actual DPI and compute expectations from it (a 120-DPI fixture gives 10cm→472px, 4in→480px).
|
||||
- Filename `%1..%6` → original-name, size-name, selected-W, selected-H, **output**-W, **output**-H (`Settings.cs:229-239`, `ResizeOperation.cs:593-601`).
|
||||
- ShrinkOnly: if target scale>1, returns `noTransformNeeded` (file copied unchanged) (`ResizeOperation.cs:462-475`).
|
||||
- KeepDateModified: `SetLastWriteTimeUtc(out, GetLastWriteTimeUtc(src))` (`ResizeOperation.cs:146-149`).
|
||||
- Replace: `File.Replace(out, src, backup)` then recycle backup — no copy left (`ResizeOperation.cs:151-156`).
|
||||
- IgnoreOrientation swap: gated by `IgnoreOrientation && !HasAuto && Unit != Percent` (`ResizeOperation.cs:419-444`).
|
||||
|
||||
## Recipes — a control/observation map, NOT a per-test-case answer key
|
||||
|
||||
> Maps each capability to **which control/CLI flag drives it** and **where the result shows**. CLI flag *names* and fit-mode/unit/field enumerations are stable IR knowledge and stay; concrete flag *values*, fixtures, and expected outputs are per-test-case — design those at runtime.
|
||||
|
||||
| # | Capability | Drive (control / CLI flag) | Observe (where the result shows) |
|
||||
|---|---|---|---|
|
||||
| 1 | Module disabled → context-menu entry absent | toggle `enabled` off + restart runner; synthetic menu (only valid observer) | "Resize with Image Resizer" absent. Gate: `dllmain.cpp:87-91` (ECS_HIDDEN), `ContextMenuHandler.cpp:70-71,383-385`. Locked desktop → BLK-ENV |
|
||||
| 2 | Module enabled → entry present (modern + classic), click launches the GUI | synthetic menu + invoke | `Get-PtContextMenuItems` shows "Resize with Image Resizer"; classic "Show more options" too; invoke → `PowerToys.ImageResizer.exe` launches |
|
||||
| 3 | Remove a built-in size / add a custom size | edit `imageresizer_sizes` (INTEGER Ids!) + launch GUI | `SizeComboBox` reflects the edit (removed gone, custom present) |
|
||||
| 4 | Resize one / multiple files end-to-end | CLI `--size <id> [files…]` | outputs at the size's Fit dimensions |
|
||||
| 5 | GIF animation warning on `.gif` input | GUI on a `.gif` | warning InfoBar present (`winapp ui inspect`) |
|
||||
| 6 | Fit modes (Fill / Fit / Stretch) | CLI `--width --height --fit <mode>` | output shape matches the mode (crop / letterbox / exact) |
|
||||
| 7 | Unit conversion (cm / inch / percent / pixel) | CLI `--unit <u>` | output px = unit converted at the image's DPI |
|
||||
| 8 | Custom filename format (`%1`..`%6` fields) | CLI `--filename <fmt>` | output filename follows the format fields |
|
||||
| 9 | "Keep date modified" | CLI `--keep-date-modified` | output mtime == source mtime (control: without the flag, differs) |
|
||||
| 10 | "Shrink only" | CLI `--shrink-only` | an already-small image is untouched (control: a large one still shrinks) |
|
||||
| 11 | "Replace original" | CLI `--replace` | original replaced in place; no `(name) (1)` copy |
|
||||
| 12 | "Ignore orientation" | settings (false) vs flag (true) | on a portrait target over a landscape image: false→no W/H swap, true→swap |
|
||||
|
||||
> **Mapping process**: read the actual checklist item → identify the capability → find its row → drive the named control/flag and design your own inputs + assertions. If no row matches, drive ad-hoc and add a row (capability + control + observation point; no canned inputs).
|
||||
|
||||
## Common BLOCKED traps (avoid)
|
||||
- **Don't mark the resize-behavior items BLOCKED for "needs a real right-click".** The CLI fully drives them with the identical engine; the menu is just the trigger (prove the menu→launch wiring once with the golden path in Recipe 2).
|
||||
- **PowerShell `ConvertTo-Json` writes computed numbers as doubles (`"Id": 3.0`)** → `System.Text.Json` rejects `imageresizer_sizes` and the app silently falls back to the 4 built-in default presets (Small/Medium/Large/Phone). Cast Ids to `[int]` or regex-strip `\.0`. This bit the remove-size-add-custom item (Recipe 3) the first time.
|
||||
- **cm/inch outputs depend on the fixture's DPI, not 96.** System.Drawing saves at the session display DPI (here 120). Compute expectations from the actual DPI.
|
||||
- **Caption is "Resize with Image Resizer", not the checklist's "Resize images"** (both menus). Hard-match the real caption.
|
||||
- **Idle auto-lock = BLK-ENV for the disabled-absent + enabled-present items (Recipes 1-2)** (synthetic right-click needs foreground). Disable lock/sleep before the run (`references/environment-setup.md`).
|
||||
|
||||
## Fixture files needed
|
||||
None pre-canned. Generate with `System.Drawing`: a landscape (e.g. 1200×800) and portrait (800×1200) JPEG, a small (100×100) PNG, a square (400×400) PNG, a `.gif` (single frame is fine — the warning is extension-based), and 3 identical images for the multi-file batch.
|
||||
|
||||
## Source citations
|
||||
- `ui/Cli/ImageResizerCliExecutor.cs`, `ui/Models/CliOptions.cs`, `ui/Cli/CliSettingsApplier.cs`, `ui/Cli/Commands/ImageResizerRootCommand.cs` — CLI surface + engine reuse.
|
||||
- `ui/Models/ResizeOperation.cs:419-501,572-617,146-156` — dimension math, filename, keep-date, replace.
|
||||
- `ui/Models/ResizeSize.cs:78-124`, `ResizeFit.cs`, `ResizeUnit.cs` — unit/fit math + enum order.
|
||||
- `ui/Properties/Settings.cs:65,105-111,229-239,431-552` — paths, defaults, JSON property names, FileNameFormat.
|
||||
- `ImageResizerContextMenu/dllmain.cpp:49,79-133,219-245,284` — modern menu title, enable+image gate, launch, caption.
|
||||
- `dll/ContextMenuHandler.cpp:21,46-48,70-71,383-385` — classic menu caption + enable gate.
|
||||
- `ui/ViewModels/InputViewModel.cs:76,143`, `ImageResizerXAML/Views/InputPage.xaml:293-299`, `Strings/en-us/Resources.resw:148-149` — gif warning.
|
||||
|
||||
## Ceiling
|
||||
**18/18 PASS** observed (2026-06-09). All 14 resize-behavior items + the enabled-entry-present-in-both-menus + the remove-size + the gif-warning items cleanly driven via CLI/GUI. The disabled-entry-absent case (in both modern + classic menus, with sibling entries remaining and the entry returning on re-enable) verified live once the desktop was unlocked. NB: an idle auto-lock will turn the menu-presence Recipes 1-2 into BLK-ENV — disable lock/sleep up front (`references/environment-setup.md`).
|
||||
|
||||
## Don'ts
|
||||
- Don't expect `Shell.Application.Verbs()` to list the entry — it's a Win11 packaged command (classic verbs are blind; `CoCreate` → `REGDB_E_CLASSNOTREGISTERED`).
|
||||
- Don't hardcode 96 DPI for cm/inch math.
|
||||
- Don't write preset Ids as JSON doubles.
|
||||
- Don't kill processes by name; use `Stop-Process -Id <pid>`.
|
||||
- Don't forget to restore `enabled."Image Resizer"=true` + restart runner, and revert any `settings.json`/`sizes.json` edits.
|
||||
77
.github/skills/powertoys-module-verification/references/modules/new-plus.md
vendored
Normal file
77
.github/skills/powertoys-module-verification/references/modules/new-plus.md
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
# New+ — module verification profile
|
||||
|
||||
**PT module**: `NewPlus` (Explorer right-click → "New+" submenu that creates files/folders from a user templates folder)
|
||||
**Source**: `<PT-repo>\src\modules\NewPlus\` (shell ext) + `<PT-repo>\src\settings-ui\Settings.UI\ViewModels\NewPlusViewModel.cs` (Settings UI)
|
||||
**Module-owned settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\NewPlus\settings.json` — **folder is `NewPlus`, NOT `New`** (matches SKILL.md pitfall #18 table). Keys: `HideFileExtension`, `HideStartingDigits`, `TemplateLocation`, `ReplaceVariables`, `BuiltInNewHidePreference`.
|
||||
**Templates folder (default)**: `%LOCALAPPDATA%\Microsoft\PowerToys\NewPlus\Templates` (per `TemplateLocation`)
|
||||
**Default-templates source**: `%LOCALAPPDATA%\PowerToys\WinUI3Apps\Assets\NewPlus\Templates` (also `%ProgramFiles%\PowerToys\...` on machine installs)
|
||||
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\NewPlus\NewPlus.ShellExtension\Logs\v<ver>\log_<date>.log`
|
||||
**Packaged command**: sparse MSIX `Microsoft.PowerToys.NewPlusContextMenu`; command CLSID `{FF90D477-E32A-4BE8-8CC5-A502A97F5401}`
|
||||
**Named Event**: none. **DSC**: n/a.
|
||||
|
||||
> Read **`../references/explorer-context-menu-flow.md` first** — New+ is a Win11 packaged-IExplorerCommand context-menu module; the menu can only be eyeballed via a real synthetic right-click on an **unlocked interactive desktop**. On a locked/RDP-minimized desktop (`Test-PtDesktopInteractive=False`) all "menu appears / template appears / hidden-caption" assertions are BLK-ENV / BLK-VISUAL-RENDER, not product FAILs.
|
||||
|
||||
## Entry-paths (try in order)
|
||||
|
||||
### 1. Enable/disable + registration gate (menu presence/absence) — headless-safe
|
||||
Flip `enabled.NewPlus` in master `settings.json` + `Restart-PtRunner`, **or** toggle the Settings switch (below). Observe the gate, no foreground needed:
|
||||
- CLSID registered ⇒ `Test-Path "HKCU:\Software\Classes\CLSID\{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"` is `True` (enabled) / `False` (disabled).
|
||||
- Log lines `New+ context menu registered` / `... unregistered` + `Runtime registration completed for CLSID ...`.
|
||||
- Sparse package stays `Status Ok` even when disabled (hidden dynamically — SKILL.md pitfall #17).
|
||||
|
||||
### 2. Settings UI toggles via UIA invoke — headless-safe, **required for template auto-copy**
|
||||
`Start-Process …\PowerToys.exe --open-settings=NewPlus`, then `winapp ui invoke <btn> -w <settingsHwnd>`:
|
||||
- Enable toggle: `btn-new-248c` (under `NewPlusEnableToggle`) — **the enable transition runs `CopyTemplateExamples`** (Settings-UI side, `NewPlusViewModel.IsEnabled` setter). A master-`settings.json` flip + runner restart does **NOT** copy templates.
|
||||
- `btn-hidethefileexte-24a0` (Hide file extension), `btn-hideleadingdigi-24a8` (Hide leading digits). AutomationIds carry a per-session suffix — re-`inspect` to get the live id.
|
||||
|
||||
### 3. Synthetic right-click on the folder **BACKGROUND** (the menu-render observer) — needs unlocked desktop
|
||||
New+ lives in the folder-background ("New") menu, **not** a file's context menu — so `pt-explorer-contextmenu.ps1`'s `Open-PtExplorerContextMenu` (which right-clicks a *file item*) is the wrong entry. Right-click an **empty area of the file list** instead, then expand the `New+` submenu (a separate popup window one level deeper):
|
||||
```powershell
|
||||
# force-foreground the CabinetWClass window, GetWindowRect, RightClick at ~45% width / 68% height (empty list area)
|
||||
# -> main bg menu popup (PopupWindowSiteBridge). Then:
|
||||
$np = (winapp ui search 'New+' -w $mainMenuHwnd --json).matches | ? type -eq MenuItem | select -First 1
|
||||
winapp ui invoke $np.selector -w $mainMenuHwnd # expands the New+ submenu
|
||||
# the submenu is the PopupWindowSiteBridge popup that contains 'Open templates' but NOT 'Sort by'
|
||||
# enumerate its MenuItems (templates are 1:1 with the Templates-folder entries) / invoke one by name
|
||||
```
|
||||
Template items render with **caption transforms applied** (HideFileExtension strips `.txt`; HideStartingDigits strips `01. `). Selecting a template creates it in the current folder + enters rename mode. BLK-ENV only if `Test-PtDesktopInteractive` is False. **No Explorer restart needed** for setting A/B — the handler re-reads `NewPlus\settings.json` on each menu build.
|
||||
|
||||
## Recipes — a control/observation map, NOT an answer key
|
||||
|
||||
| # | Capability | Drive (control / settings key) | Observe (where the result shows) |
|
||||
|---|---|---|---|
|
||||
| 1 | Menu entry present when enabled | enable (master flag + restart, or `btn-new-248c`) | CLSID registered in `HKCU\…\CLSID`, log `context menu registered`; *visible submenu* = synthetic menu only (BLK-VISUAL-RENDER if locked) |
|
||||
| 2 | Menu entry absent when disabled | disable | CLSID absent, log `context menu unregistered`; package still `Status Ok` |
|
||||
| 3 | Templates folder created empty | shell ext `create_folder_if_not_exist(root)` on menu build (delete folder → right-click) | folder recreated **empty** — needs synthetic menu (BLK-ENV if locked) |
|
||||
| 4 | Default templates copied when empty | `CopyTemplateExamples` on Settings-UI **enable** transition (`btn-new-248c` off→on) while folder empty | Templates folder repopulated from install Assets (filesystem — headless-safe) |
|
||||
| 5 | A template (file/folder) shows + creates on select | put item in Templates folder; select it in the New+ submenu | submenu item (1:1 with dir entries) + `SHFileOperation FO_COPY` to target — synthetic menu only |
|
||||
| 6 | Hide file extension | `HideFileExtension` / `btn-hidethefileexte-24a0` | strips ext from **menu caption only** (`get_menu_title`, `show_extension=false`); created file keeps ext — caption is BLK-VISUAL-RENDER if locked |
|
||||
| 7 | Hide starting digits/spaces/dots | `HideStartingDigits` / `btn-hideleadingdigi-24a8` | strips leading digits+separator from **both** menu caption and **created filename** (`remove_starting_digits_from_filename` via `get_menu_title` + `copy_template`); needs a digit-prefixed template + render |
|
||||
|
||||
> Verify a setting actually drives behavior by editing the **module-owned** `NewPlus\settings.json` (not the PT-store mirror) and relaunching; the Settings toggles round-trip into this same file.
|
||||
|
||||
## Common BLOCKED traps
|
||||
- **Master-flip + runner restart does not copy default templates** — that's a Settings-UI action (`NewPlusViewModel.IsEnabled`). Use the UIA toggle for any template-auto-copy item.
|
||||
- **Menu render is invisible without a real right-click** — packaged command is not `CoCreate`-able (`REGDB_E_CLASSNOTREGISTERED`) and not in classic `Shell.Application.Verbs()`. Locked desktop ⇒ BLK-ENV; do not substitute a CLI/back-door (there isn't one, and it'd be a false PASS).
|
||||
- **No template-count observable** — `saved_number_of_templates` is an in-memory static (`new_utilities.cpp`), not registry/log.
|
||||
|
||||
## Fixture files needed
|
||||
- A plain file (e.g. `test.txt`) and a folder-with-files to drop into Templates (template-appears items).
|
||||
- A digit-prefixed template (e.g. `01. Test.txt`) to exercise Hide-starting-digits.
|
||||
|
||||
## Source citations
|
||||
- `src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp` — `get_menu_title` (hide-extension), `remove_starting_digits_from_filename`, `copy_object_to`.
|
||||
- `src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h` — `copy_template`, `create_folder_if_not_exist`, `get_newplus_setting_hide_*`, `register_msix_package`.
|
||||
- `src/modules/NewPlus/NewShellExtensionContextMenu/shell_context_sub_menu.cpp` — `create_folder_if_not_exist(root)` + template enumeration.
|
||||
- `src/settings-ui/Settings.UI/ViewModels/NewPlusViewModel.cs` — `CopyTemplateExamples` (creates dir; copies examples only when files==0 && dirs==0), called from `IsEnabled` setter / `OpenNewTemplateFolder` / `DashboardViewModel`.
|
||||
|
||||
## Ceiling
|
||||
- Unlocked interactive desktop: **9/9 PASS** (verified 2026-06-18 via background-menu synthetic right-click + submenu expansion).
|
||||
- Locked/non-interactive desktop: ~3/9 (registration-gate present/absent + template auto-copy); menu-render/select items fall to BLK-ENV/BLK-VISUAL-RENDER — re-run after unlocking.
|
||||
|
||||
## Don'ts
|
||||
- Don't edit `…\PowerToys\New\settings.json` — wrong path; the file is under `NewPlus\`.
|
||||
- Don't use `Open-PtExplorerContextMenu` (file-item right-click) for New+ — it's the folder **background** ("New") menu; right-click empty list space instead.
|
||||
- Don't forget to **expand the `New+` submenu** (invoke it) before enumerating templates — they live one popup deeper than the main menu.
|
||||
- Don't mark menu-render items as product FAIL on a locked desktop — it's BLK-ENV.
|
||||
- Don't restart Explorer to apply a setting change — the handler re-reads `NewPlus\settings.json` per menu build.
|
||||
117
.github/skills/powertoys-module-verification/references/modules/peek.md
vendored
Normal file
117
.github/skills/powertoys-module-verification/references/modules/peek.md
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
# Peek — module verification profile
|
||||
|
||||
**PT module**: `Peek` (file previewer activated on Ctrl+Space with Explorer file selected)
|
||||
**Source**: `<PT-repo>\src\modules\Peek\` (PT repo)
|
||||
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\Peek\settings.json`
|
||||
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\Peek\Logs\v<ver>\log_<date>.log`
|
||||
**Exes**: `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.Peek.UI.exe`
|
||||
**Default hotkey**: `Ctrl+Space` (modifiers=`ctrl`, code=32; see `settings.json` → `ActivationShortcut`)
|
||||
**Named Event**: `Local\ShowPeekEvent` (friendly name: `Peek.Show` in `pt-shared-events.ps1` catalog)
|
||||
**DSC resource**: `Microsoft.PowerToys/PeekSettings`
|
||||
|
||||
## Three entry-paths (try in order)
|
||||
|
||||
### 1. CLI back-door — fastest, no Explorer needed
|
||||
```powershell
|
||||
Start-Process "$env:LOCALAPPDATA\PowerToys\WinUI3Apps\PowerToys.Peek.UI.exe" -ArgumentList "<file>"
|
||||
```
|
||||
**Source**: `Peek.UI\PeekXAML\App.xaml.cs:106-134` — when last arg is not int (=runner PID) and is an existing file, it sets `_launchedFromCli=true`, builds `SelectedItemByPath`, calls `OnShowPeek()`. Bypasses hotkey + Explorer foreground.
|
||||
|
||||
**Use for**: single-file previewer rendering tests (Recipes 1-2) and the CLI-accepts-path assertion (Recipe 8).
|
||||
|
||||
**Cannot use for**: navigation tests (Recipes 4-7, 10-11) — source has `if (_isFromCli) return;` guard that disables arrow navigation, and CLI mode spawns a fresh process every call (no pin-state-across-reopen).
|
||||
|
||||
### 2. Shell.Application COM + Ctrl+Space — Explorer-driven, supports navigation
|
||||
This is the canonical "do what a real user would do" path that drives all the navigation/pin tests.
|
||||
|
||||
```powershell
|
||||
# Dot-source helpers first
|
||||
. "$skill\scripts\pt-explorer-com.ps1"
|
||||
. "$skill\scripts\pt-sendinput-chord.ps1"
|
||||
|
||||
# Set up multi-file selection in Explorer + trigger Peek in one call:
|
||||
$peekHwnd = Invoke-PtPeekWithExplorerSelection `
|
||||
-FolderPath 'D:\fixtures' `
|
||||
-FileNames 'test-markdown.md','test-html.html','test-source.cs'
|
||||
|
||||
# Now Peek is open over a 3-file IShellItemArray. Test:
|
||||
winapp ui invoke 'PinButton' -w $peekHwnd # pin
|
||||
# (move window via SetWindowPos)
|
||||
Send-PtChord -Key 0x27 # Right arrow → switch file
|
||||
# verify the pinned position stuck
|
||||
```
|
||||
|
||||
**Use for**: pin behavior, multi-file navigation, file switching (Recipes 4-7, 10-11).
|
||||
|
||||
**Requires**: interactive desktop session (`Test-PtInteractiveDesktop` must show both `ForegroundOk=True` and `ShellComOk=True`).
|
||||
|
||||
### 3. Named Event signal — quick smoke
|
||||
```powershell
|
||||
Invoke-PtSharedEvent -Name 'Peek.Show'
|
||||
```
|
||||
Wakes the resident Peek process (different from CLI back-door — respects current Explorer foreground selection). Used by some framework tests for the "Peek is enabled and listening" assertion.
|
||||
|
||||
## Recipes — a control/observation map, NOT a per-test-case answer key
|
||||
|
||||
> Maps each Peek *capability* to **how to drive it** and **where the result shows**. It does NOT prescribe concrete fixtures/coords/inputs or expected values — design those at runtime from the actual checklist item. Only a real UI/behavior change should force an edit here.
|
||||
|
||||
| # | Capability | Drive (control / command) | Observe (where the result shows) |
|
||||
|---|---|---|---|
|
||||
| 1 | File-type previewer renders (image / text+code / markdown / PDF / HTML / archive / unsupported) | `Peek.UI.exe <fixture>` (entry-path 1) → `winapp ui inspect -w <hwnd> --depth 7` | the type's previewer node present (`ImagePreview Image`; `PreviewBrowser Pane` for dev/text/md/HTML; archive tree for zip; File-Type/Size/Date view for unsupported). Prefer `winapp ui search` for an in-fixture marker over OCR |
|
||||
| 2 | "Open with default app" via button | `winapp ui invoke LaunchAppButton` | a new editor process/window for `<file>` appears (PID diff) |
|
||||
| 3 | "Open with default app" via Enter | `Assert-PtForegroundOrAbort` → `Send-PtChord -Key <Enter>` | same as #2 |
|
||||
| 4 | Pin keeps window position when switching files | Shell COM + Ctrl+Space (entry-path 2) → `winapp ui invoke PinButton` → move window → navigate to next file | window stays at the pinned coordinates |
|
||||
| 5 | Pin position persists across close + reopen | pinned → Esc to close (graceful — **don't `Stop-Process`**, it bypasses the pin-save handler) → reopen via Shell COM + Ctrl+Space | new window opens at the same pinned coordinates |
|
||||
| 6 | Unpin releases the lock; switching file reverts to default | `winapp ui invoke PinButton` again (unpin) → navigate | window moves to the default position |
|
||||
| 7 | Unpinned reopen uses default position | unpinned → Esc-close → reopen | new window at default, not the stale pinned coords |
|
||||
| 8 | `Peek.UI.exe <file>` CLI opens Peek | entry-path 1 | covered by #1 across file types |
|
||||
| 9 | Concurrent Peek sessions don't crash/interfere | launch `Peek.UI.exe` several times on different files, leaving windows open | each spawns its own process/window; no error in `Peek\Logs` |
|
||||
| 10 | Arrow keys cycle between selected files | Shell COM multi-file selection → Ctrl+Space → `Send-PtChord` Right/Left | window title updates to each file in sequence, wraps at the ends |
|
||||
| 11 | Multi-file selection scopes navigation | select a subset of a folder → navigate | only the selected files cycle, not the rest |
|
||||
| 12 | Activation-hotkey reassignment takes effect | edit `Peek\settings.json` `properties.ActivationShortcut` → `Restart-PtRunner` (**not hot-reloaded** — see Gotchas) → press the new chord, then the old chord | new chord opens Peek; old chord does nothing |
|
||||
|
||||
> **Mapping process**: read the actual checklist item → identify the capability → find its row → drive the named control and design your own inputs + assertions for *that* item. If no row matches, it's a NEW capability — drive ad-hoc and add a row (capability + control + observation point; no canned inputs).
|
||||
|
||||
|
||||
|
||||
## BLOCKED triage (single source of truth)
|
||||
|
||||
If the agent only tried the CLI back-door and marked the pin / navigation tests BLOCKED → **misdiagnosis**, try entry-path #2 (Shell.Application COM + Ctrl+Space).
|
||||
|
||||
If the agent tried Shell COM + Ctrl+Space and got `GetForegroundWindow()=0` + `SendInput → ACCESS_DENIED (5)` → **environment**, not framework. The session has no attached input desktop (RDP minimized, screen locked, screensaver, etc.). See `SKILL.md` pitfall #13 and `references/environment-setup.md` for the per-scenario table + powercfg setup commands. Mark BLK-ENV with mitigation citation.
|
||||
|
||||
Both traps were observed in 2026-06-08 sign-off runs; preventing both is now the agent's pre-flight job (`pt-session-diagnose.ps1`).
|
||||
|
||||
## Fixture files needed
|
||||
|
||||
Put these in a workspace `fixtures/` folder before starting:
|
||||
- `small-image.png` (any 200x150 PNG)
|
||||
- `Program.cs` (any C# file)
|
||||
- `readme.md` (markdown with H1 + bold + bullet list)
|
||||
- `test-pdf.pdf` (PDF with embedded text "PDF_FIXTURE_OK" + "PDF_MARKER_42")
|
||||
- `page.html` (HTML with `<h1>` containing "HTMLPEEKMARKER")
|
||||
- `archive.zip` (zip containing 1 small text file)
|
||||
- `unsupported.xyz` (any small binary)
|
||||
- 3 differently-sized images for the pin-position tests (e.g. 320x240, 800x600, 1920x1080)
|
||||
|
||||
## Source citations
|
||||
|
||||
- `<PT-repo>\src\modules\Peek\Peek.UI\PeekXAML\App.xaml.cs:106-134` — CLI arg parsing, `_isFromCli` flag, OnShowPeek call.
|
||||
- `<PT-repo>\src\modules\Peek\Peek.UI\PeekXAML\Models\NavigationManager.cs` — `// TODO: implement navigation` + `if (_isFromCli) return;` guards.
|
||||
- `<PT-repo>\src\common\interop\shared_constants.h` — `ShowPeekEvent` name.
|
||||
|
||||
## Ceiling
|
||||
|
||||
**18/18 = 100%** achievable from a normal interactive admin console session (verified 2026-06-08). The change-shortcut item is PASS-able via the settings.json + runner-restart path — see Recipe 12.
|
||||
|
||||
## Peek-specific gotchas
|
||||
|
||||
- **Activation-shortcut is NOT hot-reloaded.** Editing `Peek\settings.json` `ActivationShortcut` and waiting for the file-watcher debounce does nothing — the centralized keyboard hook only re-registers the chord after `Restart-PtRunner`. Restart after the change AND again after restoring.
|
||||
- **PinButton spawns a `PopupHost` teaching-tip.** Invoking `PinButton` pops a small confirmation flyout (≈192x63) titled `PopupHost` that surfaces *first* in `winapp ui list-windows`. A naive "first HWND" regex grabs the popup, not Peek. Match by title suffix `- Peek` (regex like `HWND (\d+): "([^"]*- Peek)"`) and/or cache the original Peek HWND before invoking PinButton.
|
||||
- **Win11 Notepad tabs/session-restore** muddy the "open-with-default-app" tests (Recipes 2-3): the spawned Notepad restores prior tabs, so the foreground Notepad's title may not show your file. Enumerate all Notepad windows and match `"<file> - Notepad"` explicitly.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't `Stop-Process PowerToys.Peek.UI -Force`** to close Peek between iterations — bypasses the save handler, breaks the pin-state-persistence tests (Recipes 5, 7). Use Esc / `winapp ui invoke CloseButton`.
|
||||
- **Don't assume CLI back-door supports navigation** — it doesn't (`_isFromCli` guard). For nav tests use Shell COM + Ctrl+Space.
|
||||
- **Don't OCR the previewer surface** when UIA already exposes the correct nodes (`ImagePreview`, `PreviewBrowser`, `LaunchAppButton`, `PinButton`). UIA is more reliable than OCR.
|
||||
114
.github/skills/powertoys-module-verification/references/modules/power-rename.md
vendored
Normal file
114
.github/skills/powertoys-module-verification/references/modules/power-rename.md
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
# PowerRename — module verification profile
|
||||
|
||||
**PT module**: `PowerRename` (bulk-rename UI launched via Explorer context menu on selected files/folders)
|
||||
**Source**: `<PT-repo>\src\modules\PowerRename\` (PT repo)
|
||||
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\PowerRename\settings.json`
|
||||
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\PowerRename\Logs\v<ver>\log_<date>.log`
|
||||
**Exe**: `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.PowerRename.exe`
|
||||
**Activation**: Explorer right-click → "Rename with PowerRename" (Win11 Tier-1 menu; **no classic HKCR verb on Win11**); optional global hotkey if user-configured
|
||||
**DSC resource**: `Microsoft.PowerToys/PowerRenameSettings`
|
||||
|
||||
## Shared mechanics
|
||||
|
||||
For the synthetic-right-click + context-menu-invoke flow that ALL Explorer-context-menu modules use, see **`references/explorer-context-menu-flow.md`** + **`scripts/pt-explorer-contextmenu.ps1`** (`Test-PtDesktopInteractive`, `Open-PtExplorerContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems`). That doc covers stability rules, multi-file selection, BLK-ENV handling, and module-caption table. Don't duplicate; cite by section.
|
||||
|
||||
For the Win11 IExplorerCommand vs classic HKCR distinction, see `scripts/pt-shell-verbs.ps1` header — PR is **modern-menu-only on Win11**, so classic-verb enumeration via Shell.Application **will not find it**.
|
||||
|
||||
## Entry-paths (try in order)
|
||||
|
||||
### 1. Direct CLI launch with file args — PREFERRED for UI-driven tests (verified 2026-06-10)
|
||||
```powershell
|
||||
$tmp = New-Item -ItemType Directory -Path "$env:TEMP\pr-fixture-$(Get-Random)"
|
||||
1..3 | ForEach-Object { 'x' | Set-Content "$($tmp.FullName)\file$_.txt" }
|
||||
|
||||
Start-Process "$env:LOCALAPPDATA\PowerToys\WinUI3Apps\PowerToys.PowerRename.exe" `
|
||||
-ArgumentList "$($tmp.FullName)\file1.txt","$($tmp.FullName)\file2.txt","$($tmp.FullName)\file3.txt"
|
||||
|
||||
Start-Sleep -Milliseconds 1500
|
||||
$pr = (winapp ui list-windows -a PowerToys.PowerRename 2>$null | Out-String) -split "`r?`n" |
|
||||
ForEach-Object { if ($_ -match 'HWND (\d+):') { [int64]$matches[1] } } | Select-Object -First 1
|
||||
winapp ui inspect -w $pr --depth 5 -i 2>$null | Out-String | Select-String 'CheckBox "file\d\.txt"'
|
||||
# Expect 3 hits (file1/2/3.txt, [on] by default)
|
||||
```
|
||||
Bypasses the context menu entirely; same code path inside the exe (it parses argv as the file list). **Use for every UI-driven option/regex/preview test** (Recipes 4-12 below).
|
||||
|
||||
### 2. Synthetic right-click + Invoke-PtContextMenuItem — for "menu entry present/absent" assertions (Recipes 1-3)
|
||||
Use the canonical flow from `references/explorer-context-menu-flow.md` Recipe. The menu-presence assertion is the ONE thing the CLI back-door cannot prove (it works even if the menu entry is correctly hidden — the false-positive trap described in that doc).
|
||||
|
||||
```powershell
|
||||
. "$skill\scripts\pt-explorer-contextmenu.ps1"
|
||||
$hwnd = Open-PtExplorerContextMenu -FolderPath 'D:\fixtures' -FileNames 'a.txt'
|
||||
$items = Get-PtContextMenuItems -MenuHwnd $hwnd
|
||||
$has = $items | Where-Object Name -match 'Rename with PowerRename'
|
||||
# assert $has -> entry present
|
||||
```
|
||||
|
||||
### 3. Shell COM classic verb (does NOT work on Win11 stock install)
|
||||
```powershell
|
||||
Invoke-PtShellVerb -Path 'D:\fixtures\a.txt' -NamePattern 'PowerRename' # -> False
|
||||
```
|
||||
Returns False on Win11 because PT registers PR only via IExplorerCommand, not as a classic HKCR shell verb. **Use only for negative checks** (and prefer the synthetic-menu enumeration above, which observes the actual Tier-1 menu).
|
||||
|
||||
## Recipes — a control/observation map, NOT a per-test-case answer key
|
||||
|
||||
> **What this table is (and isn't):** it maps each PowerRename *capability* to **which control drives it** (AutomationId / settings key) and **where the result shows up**. It deliberately does **NOT** prescribe specific Search/Replace inputs or expected-output assertions — those are the agent's job to design from the actual checklist item at runtime. Keeping it input/assertion-free means the table survives checklist-wording changes; only a real UI redesign (renamed/moved control) should force an edit here (as happened to rows 5 & 12 in build 0.100.0).
|
||||
|
||||
| # | Capability | Drive (control / settings key) | Observe (where the result shows) |
|
||||
|---|---|---|---|
|
||||
| 1 | Context-menu entry present when enabled, gone when disabled | master `enabled.PowerRename` flip + `Restart-PtRunner`; synthetic menu (entry-path 2) | `Get-PtContextMenuItems` includes / excludes "Rename with PowerRename" |
|
||||
| 2 | "Show icon on context menu" | `ShowIcon` in `power-rename-settings.json` + relaunch | menu entry shows icon vs text-only (screenshot); or HKCR `Icon` |
|
||||
| 3 | "Appear only in extended menu" | `ExtendedContextMenuOnly` + relaunch | Tier-1 menu hides PR; classic "Show more options" still lists it |
|
||||
| 4 | Any search/replace option toggle (regex, match-all, case-sensitive, autocomplete, last-use) | `winapp ui invoke checkBox_regex` / `checkBox_matchAll` / `checkBox_case` (etc.); re-read `power-rename-settings.json` | the settings key flips **and** the preview behavior changes accordingly |
|
||||
| 5 | Case mode (single-select) | toggle **buttons** `toggleButton_lowerCase` / `upperCase` / `titleCase` / `capitalize` (not a dropdown) | preview column shows case-transformed names |
|
||||
| 6 | Scope: include/exclude Files / Folders / Subfolders | `toggleButton_includeFiles` / `includeFolders` / `includeSubfolders` | excluded row types appear disabled in the preview |
|
||||
| 7 | Apply-to scope: name-only / extension-only | the "Apply to" selector | replacement affects only the name vs only the extension (preview) |
|
||||
| 8 | Enumerate items | `toggleButton_enumItems`; Replace accepts `${start=,increment=,padding=}` tokens | preview shows the substituted counter |
|
||||
| 9 | Datetime tokens | Replace accepts `$DD` `$MMMM` `$YYYY` `$hh` `$mm` `$ss` `$fff` | preview value matches `(Get-Item <file>).CreationTime` formatted the same way |
|
||||
| 10 | Boost library (Perl regex beyond .NET, e.g. lookbehind) | `UseBoostLib` — **read at process start; relaunch PR after toggling** | the Perl-only pattern matches in the preview without error |
|
||||
| 11 | Per-row include/exclude in the preview | invoke a row checkbox to uncheck | the unchecked file is unchanged on disk after Rename |
|
||||
| 12 | Filter preview / select-all (NOT a column-header click — headers `TxtBlock_Original`/`TxtBlock_Renamed` are non-interactive labels) | `btn-filter-XXXX` → `button_showAll` / `button_showRenamed`; `checkBox_selectAll` | visible row set shrinks/grows; all rows toggle on/off |
|
||||
|
||||
> **Mapping process**: read the actual checklist item → identify the capability → find its row → drive the named control and design your own inputs + assertions for *that* item. If no row matches, it's a NEW capability — drive it ad-hoc and add a row (capability + control + observation point, no canned inputs).
|
||||
|
||||
|
||||
|
||||
## Fixture files needed
|
||||
|
||||
In a workspace `fixtures/` folder:
|
||||
- `a.txt`, `b.txt`, `c.txt` — multi-select
|
||||
- `IMG_001.png`, `IMG_002.png`, `IMG_003.png` — regex capture
|
||||
- subfolder `subdir/` with 2 inner files — folder/subfolder exclusion
|
||||
- `Foo_A_A_A.txt` — match-all
|
||||
- `MIXED.txt` — case-sensitive
|
||||
|
||||
Always copy fixtures to a disposable temp folder before running actual rename operations.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **TWO settings files — PR reads `power-rename-settings.json`, NOT `settings.json`** (verified 2026-06-10). `%LOCALAPPDATA%\Microsoft\PowerToys\PowerRename\` holds both: (1) `settings.json` = PT-store, keys `bool_mru_enabled`/`bool_persist_input`/`bool_show_icon_on_menu`/`bool_show_extended_menu`/`bool_use_boost_lib`/`int_max_mru_size` (what `Get-PtModuleSettings` + the Settings UI bind to); (2) `power-rename-settings.json` = the module's own store, keys `ShowIcon`/`ExtendedContextMenuOnly`/`PersistState`/`MRUEnabled`/`MaxMRUSize`/`UseBoostLib` — **this is the file the PR UI exe and the context-menu COM handlers actually read at launch** (`lib/Settings.cpp` `CSettings::Load→ParseJson`). The runner (`dll/dllmain.cpp:301-307`) syncs PT-store→module-store only on a Settings-UI *change event*; the PT-store file can sit stale for days. **To drive ShowIcon / ExtendedContextMenuOnly / MRUEnabled / PersistState / UseBoostLib deterministically, edit `power-rename-settings.json` directly + relaunch PR (or restart runner+Explorer for the menu handlers), then restore.** Map (settings.json key → user-facing toggle): ShowIcon→"Show icon on context menu", ExtendedContextMenuOnly→"Appear only in extended menu", MRUEnabled→autocomplete, PersistState→"Show values from last use", UseBoostLib→"Use Boost library". MRU values live in `search-mru.json`/`replace-mru.json`; last-used (persist) in `power-rename-last-run-data.json`.
|
||||
- **"Show icon on context menu" has no Settings-UI toggle in current builds** — drive it via `power-rename-settings.json` `ShowIcon`. Behavior is observable on the synthetic menu (icon vs text-only); source `PowerRenameContextMenu/dllmain.cpp:73` (`GetIcon→null`).
|
||||
- **The "Appear only in extended menu" classic `#32768` popup is not winapp-enumerable** — assert the Tier-1 *hide* (observed; `dllmain.cpp:108` `ECS_HIDDEN`) and cite `PowerRenameExt.cpp:84` (`E_FAIL` unless `CMF_EXTENDEDVERBS`) for the "still in extended menu" half.
|
||||
- **PR registers on the directory *background* menu too** — the synthetic right-click often lands on background (View/Sort by/Group by/...) yet still shows/hides `Rename with PowerRename`, which is a valid, stable surface for menu-entry / icon-visibility / extended-menu-only present-absent comparisons.
|
||||
- **`set-value` on search/replace DOES fire the preview** (TextChanged works, unlike CmdPal) — Apply button enabling/disabling is a reliable match/no-match signal. The search/replace Edit AutomationIds are random per launch (`txt-textbox-XXXX`); discover them each launch by name (`Edit "Search for"` / `Edit "Replace with"`).
|
||||
- **Preview-row uncheck + column-header invokes need the Preview populated first** — set Search/Replace and wait ~500 ms for the regex engine; otherwise the invokes hit an empty list.
|
||||
- **Boost library is read at PR process start** — close + relaunch PR after toggling.
|
||||
- **Icon-on-menu and extended-only checks prefer registry over screenshot** — read HKCR `Extended` / `Icon` REG_SZ; more reliable + locale-independent.
|
||||
- **Disk mutation is real** — run renames against `$env:TEMP\pr-test-<random>`, not real fixtures.
|
||||
- **COM cache staleness** when re-checking verbs after enable/disable — call `Reset-PtShellComCache` from `scripts/pt-shell-verbs.ps1`.
|
||||
|
||||
## Source citations
|
||||
|
||||
- `<PT-repo>\src\modules\PowerRename\dllmain.cpp` — IExplorerCommand registration (no classic HKCR shadow on Win11).
|
||||
- `<PT-repo>\src\modules\PowerRename\PowerRenameUILib\` — XAML for main PR window (toggle/checkbox AutomationIds).
|
||||
- `<PT-repo>\src\modules\PowerRename\PowerRenameLib\Settings.cpp` — settings.json schema canonical property names.
|
||||
|
||||
## Ceiling
|
||||
|
||||
Expected **18/18 = 100%** from an interactive admin console session. Direct-CLI (#1) covers UI-driven items; synthetic-menu (#2) covers menu-presence assertions.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't** try `Invoke-PtShellVerb 'PowerRename'` — returns False on Win11 (no classic registration). Use synthetic menu via `Invoke-PtContextMenuItem` or direct-CLI.
|
||||
- **Don't** run rename operations against reusable fixtures — copy to a disposable temp folder.
|
||||
- **Don't** trust screenshot-only for icon-on-menu or extended-only checks — registry inspection is faster + locale-independent.
|
||||
- **Don't** skip the synthetic-menu test for the menu-presence assertion — CLI back-door PASSes even when the menu entry is correctly hidden (false-positive trap described in `references/explorer-context-menu-flow.md`).
|
||||
122
.github/skills/powertoys-module-verification/references/pre-flight.md
vendored
Normal file
122
.github/skills/powertoys-module-verification/references/pre-flight.md
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
# Pre-flight checks, bootstrap, and state hygiene
|
||||
|
||||
This doc covers the **agent-runtime** environment probing and lifecycle hooks. Read alongside `SKILL.md` (the playbook) and `references/environment-setup.md` (one-time user env prep).
|
||||
|
||||
## Pre-flight checks (do these first; abort if any fails)
|
||||
|
||||
1. **Admin check** — `Test-PtAdmin` must return the elevation level matching `[ADMIN: YES]` items in the module's checklist. If the module contains `[ADMIN: YES]` items and `Test-PtAdmin` returns `False`, **STOP** and tell the user "this module requires an elevated session". Do NOT silently mark those items BLOCKED-LACK-ADMIN — that hides a fixable env issue.
|
||||
|
||||
2. **PT runner present** — `Test-PtRunnerAdmin` should show the runner exists. If it doesn't exist, start PowerToys (`Start-Process "$env:LOCALAPPDATA\PowerToys\PowerToys.exe"`).
|
||||
|
||||
3. **Module installed** — `Get-PtModuleSettings -ModuleDir <ModuleDir>` (or `Get-CmdPalSettings` for CmdPal) returns non-null.
|
||||
|
||||
4. **Interactive-desktop availability + session attachment** — the single most common cause of false-BLOCKED reports is a session mismatch where the agent runs in an elevated **non-console session** (e.g. RDP that's been disconnected/minimized, fast user switching, run-as-different-user, or scheduled-task-with-highest-privilege). In that scenario `Test-PtAdmin=True` but `GetForegroundWindow()=0` and `SendInput` returns `ERROR_ACCESS_DENIED (5)` — input injection cannot reach the active desktop.
|
||||
|
||||
```powershell
|
||||
# Sessions
|
||||
$agentSession = [Diagnostics.Process]::GetCurrentProcess().SessionId
|
||||
$consoleSession = (Get-Process explorer -EA SilentlyContinue | Select-Object -First 1).SessionId
|
||||
"Agent session=$agentSession Console explorer session=$consoleSession"
|
||||
|
||||
# Foreground + Shell COM probe (use scripts/pt-session-diagnose.ps1 for the full version)
|
||||
Add-Type 'using System; using System.Runtime.InteropServices; public class FG4 { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); }'
|
||||
$hasFg = $false
|
||||
for ($i = 0; $i -lt 5; $i++) { if ([FG4]::GetForegroundWindow() -ne [IntPtr]::Zero) { $hasFg=$true; break }; Start-Sleep -Milliseconds 200 }
|
||||
$shellOk = $false
|
||||
try { $shellOk = (@((New-Object -ComObject Shell.Application).Windows()).Count -ge 0) } catch {}
|
||||
"Interactive desktop: ForegroundOk=$hasFg ShellComOk=$shellOk"
|
||||
|
||||
if (-not $hasFg -and $agentSession -ne $consoleSession) {
|
||||
Write-Host "===========================================================" -ForegroundColor Red
|
||||
Write-Host "NON-INTERACTIVE SESSION DETECTED" -ForegroundColor Red
|
||||
Write-Host "Agent is in Session $agentSession but the active console is Session $consoleSession." -ForegroundColor Red
|
||||
Write-Host "SendInput, global hotkeys, and arrow-key navigation will NOT work here." -ForegroundColor Red
|
||||
Write-Host "Items requiring input injection will be marked BLK-ENV up-front." -ForegroundColor Red
|
||||
Write-Host "Mitigation: see references/environment-setup.md, or relaunch in console session:" -ForegroundColor Yellow
|
||||
Write-Host " psexec -accepteula -h -i $consoleSession -s pwsh.exe" -ForegroundColor Yellow
|
||||
Write-Host "===========================================================" -ForegroundColor Red
|
||||
# Continue verification — schema/UIA/CLI-based tests still produce real evidence
|
||||
}
|
||||
```
|
||||
|
||||
**Key distinction** (all rows assume `Admin=True`):
|
||||
- **ForegroundOk + ShellComOk** → Everything works — interactive elevated session.
|
||||
- **ShellComOk only (ForegroundOk false)** → Non-interactive (e.g. Session ≠ console, RDP minimized, screen locked, screensaver). Only schema / UIA-invoke / CLI / Named-Event tests work. Mark input-injection items as `BLK-ENV` and **cite `references/environment-setup.md` in the report** so the user can fix env and re-run.
|
||||
- **Neither (ShellComOk false)** → Session 0 / service context — even Shell COM fails. Very few tests possible.
|
||||
|
||||
5. **Discipline: try AT LEAST 2 distinct entry-paths before marking BLOCKED.** For Peek/FZ/Workspaces/Image Resizer/PowerRename/File Locksmith specifically, the obvious entry-path is the global hotkey but Shell.Application COM driving Explorer also works — see per-module profiles under `references/modules/`. Marking BLOCKED after trying only the CLI launch (a common trap) hides easily-PASS-able items in an interactive session.
|
||||
|
||||
## Bootstrap (paste at start of your verification script)
|
||||
|
||||
```powershell
|
||||
$skill = '<this skill folder>' # the folder containing SKILL.md
|
||||
Get-ChildItem "$skill\scripts" -Filter '*.ps1' | ForEach-Object { . $_.FullName }
|
||||
|
||||
$workspace = "$env:TEMP\verify-<Module>-$(Get-Date -Format yyyyMMdd-HHmmss)"
|
||||
New-Item -ItemType Directory -Path $workspace, "$workspace\artifacts" | Out-Null
|
||||
$report = "$workspace\verify-<Module>.md"
|
||||
|
||||
"# <Module> verification — $(Get-Date -Format 'yyyy-MM-dd HH:mm')" | Set-Content $report
|
||||
"" | Add-Content $report
|
||||
"## Pre-flight" | Add-Content $report
|
||||
"- IsAdmin: $(Test-PtAdmin)" | Add-Content $report
|
||||
"- PT runner: PID=$((Test-PtRunnerAdmin).Pid) Elevated=$((Test-PtRunnerAdmin).Elevated)" | Add-Content $report
|
||||
|
||||
# Then proceed with pre-flight checks #4-#6 above and write their results into the report.
|
||||
```
|
||||
|
||||
## State hygiene (CRITICAL — always restore)
|
||||
|
||||
Wrap any settings/registry mutation in try/finally:
|
||||
|
||||
```powershell
|
||||
# Per-item: settings.json edits
|
||||
$bk = Backup-PtModuleSettings -ModuleDir <ModuleDir>
|
||||
try {
|
||||
# ... mutate + assert ...
|
||||
} finally {
|
||||
Restore-PtModuleSettings -ModuleDir <ModuleDir> -BackupPath $bk
|
||||
}
|
||||
|
||||
# After GPO/admin tests
|
||||
Remove-Item HKLM:\Software\Policies\PowerToys -Recurse -Force -EA SilentlyContinue
|
||||
Remove-Item HKCU:\Software\Policies\PowerToys -Recurse -Force -EA SilentlyContinue
|
||||
Remove-Item 'C:\Windows\PolicyDefinitions\PowerToys.admx' -Force -EA SilentlyContinue
|
||||
Remove-Item 'C:\Windows\PolicyDefinitions\en-US\PowerToys.adml' -Force -EA SilentlyContinue
|
||||
|
||||
# Spawned processes (notepad, regedit, etc.) — kill by PID, not by name
|
||||
foreach ($pid in $spawnedPids) { Stop-Process -Id $pid -Force -EA SilentlyContinue }
|
||||
```
|
||||
|
||||
## Final wrap-up (run AFTER all per-item tables are written)
|
||||
|
||||
1. **Run state-hygiene cleanup** above for everything that wasn't restored per-item.
|
||||
2. **Write the top-of-report summary** per `references/reporting-format.md` §B.
|
||||
3. **Write the §G Retrospective** — reflect on the run itself: every friction (classified by source + severity + minutes/attempts cost + suggested fix), or `Everything was smooth — no friction encountered.` See `references/reporting-format.md` §G. Don't skip it; it's how the skill improves.
|
||||
4. **Verify every screenshot referenced in the report actually exists on disk** (before the move, while paths still resolve under `$workspace`):
|
||||
```powershell
|
||||
$missing = Get-Content $report | Select-String 'artifacts/L\d+/step-\d+-[^\.\s]+\.(png|txt|log|json|ps1)' -AllMatches |
|
||||
ForEach-Object { $_.Matches.Value } | Sort-Object -Unique |
|
||||
Where-Object { -not (Test-Path (Join-Path $workspace $_)) }
|
||||
if ($missing) { Write-Warning "Missing artifacts: $($missing -join ', ')" }
|
||||
```
|
||||
5. **Move the workspace to the sign-off archive** (LAST step, after the report + artifact check pass):
|
||||
```powershell
|
||||
$signoff = "$env:OneDrive\PowerToys\Module-Signoff"
|
||||
New-Item -ItemType Directory -Path $signoff -Force | Out-Null
|
||||
$final = Join-Path $signoff (Split-Path $workspace -Leaf)
|
||||
Move-Item -Path $workspace -Destination $final -Force
|
||||
$report = Join-Path $final (Split-Path $report -Leaf)
|
||||
```
|
||||
The report uses **relative** `artifacts/…` paths, so the whole tree moves intact.
|
||||
6. **Print the FINAL (moved) report path** as the very last line of your response — the `…\Module-Signoff\verify-<Module>-<timestamp>\verify-<Module>.md` path, NOT the temp path.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Never silently send keys via SendInput** to a target window without first calling `Assert-PtForegroundOrAbort -AppId <id>`. Keys silently leak to your terminal if the target isn't foreground.
|
||||
- **Never mark BLOCKED without trying at least 2 distinct entry-paths from the drive-stack** (SKILL.md §2). If you can't drive the item, name the specific obstacle (not "I can't").
|
||||
- **Never assume any external repo is cloned locally.** The helpers under `scripts/` are self-contained. Use `Test-Path` guards before referencing any external path.
|
||||
- **Never invent test steps for a `[CLARITY: VAGUE-*]` item** — mark it **FAIL (cause: checklist-ambiguous)** and quote the original wording so the user can fix the checklist. The checklist is test code; an undefinable test is a broken test.
|
||||
- **Always restore state** before exiting (even on error). State hygiene wraps every mutation in try/finally.
|
||||
- **Separate the two FAIL causes**: *product* FAILs are bugs to file; *checklist* FAILs (stale feature or ambiguous spec) are items to rewrite/prune. If a large share of a module's items are checklist-FAILs, the checklist needs an overhaul before re-verifying — don't punt drivable items into a FAIL.
|
||||
- **Never continue past 3 consecutive errors against the same item** — mark it BLOCKED with the concrete symptom/obstacle and move on. Per-item budget is ~5 minutes; if stuck longer, it's BLOCKED (name the wall).
|
||||
@@ -0,0 +1,45 @@
|
||||
# Environment Variables — PowerToys release checklist
|
||||
|
||||
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
|
||||
|
||||
## Legend
|
||||
|
||||
Each item is annotated with two metadata tags:
|
||||
|
||||
**Admin requirement**:
|
||||
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
|
||||
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
|
||||
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
|
||||
|
||||
**Clarity**:
|
||||
- (no marker) - clear, has explicit assert
|
||||
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
|
||||
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
|
||||
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
|
||||
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (20 items)
|
||||
|
||||
- [ ] **[ADMIN: YES]** (L791) Launch as administrator ON - Launch Environment Variables and confirm that SYSTEM variables ARE editable and Add variable button is enabled
|
||||
- [ ] **[ADMIN: YES]** (L792) Launch as administrator OFF - Launch Environment Variables and confirm that SYSTEM variables ARE NOT editable and Add variable button is disabled
|
||||
- [ ] **[ADMIN: NO]** (L795) Add new User variable. Open OS Environment variables window and confirm that added variable is there. Also, confirm that it's added to "Applied variables" list.
|
||||
- [ ] **[ADMIN: NO]** (L796) Edit one User variable. Open OS Environment variables window and confirm that variable is changed. Also, confirm that change is applied to "Applied variables" list.
|
||||
- [ ] **[ADMIN: NO]** (L797) Remove one User variable. Open OS Environment variables window and confirm that variable is removed. Also, confirm that variable is removed from "Applied variables" list.
|
||||
- [ ] **[ADMIN: NO]** (L801) Add new profile with no variables and name it "Test_profile_1" (referenced below by name)
|
||||
- [ ] **[ADMIN: NO]** (L802) Edit "Test_profile_1": Add one new variable to profile e.g. name: "profile_1_variable_1" value: "profile_1_value_1"
|
||||
- [ ] **[ADMIN: NO]** (L803) Add new profile "Test_profile_2": From "Add profile dialog" add two new variables (profile_2_variable_1:profile_2_value_1 and profile_2_variable_2:profile_2_value_2). Set profile to enabled and click Save. Open OS Environment variables window and confirm that all variables from the profile are applied correctly. Also, confirm that "Applied variables" list contains all variables from the profile.
|
||||
- [ ] **[ADMIN: NO]** (L804) Apply "Test_profile_1" while "Test_profile_2" is still aplpied. Open OS Environment variables window and confirm that all variables from Test_profile_2 are unapplied and that all variables from Test_profile_1 are applied. Also, confirm that state of "Applied variables" list is updated correctly.
|
||||
- [ ] **[ADMIN: NO]** (L805) Unapply applied profile. Open OS Environment variables window and confirm that all variables from the profile are unapplied correctly. Also, confirm that "Applied variables" list does not contain variables from the profile.
|
||||
- [ ] **[ADMIN: NO]** (L808) To "Test_profile_1" add one existing variable from USER variables, e.g. TMP. After adding, change it's value to e.g "test_TMP" (or manually add variable named TMP with value test_TMP).
|
||||
- [ ] **[ADMIN: NO]** (L809) Apply "Test_profile_1". Open OS Environment variables window and confirm that TMP variable in USER variables has value "test_TMP". Confirm that there is backup variable "TMP_PowerToys_Test_profile_1" with original value of TMP var. Also, confirm that "Applied variables" list is updated correctly - there is TMP profile variable, and backup User variable..
|
||||
- [ ] **[ADMIN: NO]** (L810) Unapply "Test_profile_1". Open OS Environment variables window and confirm that TMP variable in USER variable has original value and that there is no backup variable. Also, confirm that "Applied variables" list is updated correctly.
|
||||
- [ ] **[ADMIN: NO]** (L813) In "Applied variables" list confirm that PATH variable is shown properly: value of USER Path concatenated to the end of SYSTEM Path.
|
||||
- [ ] **[ADMIN: NO]** (L814) To "Test_profile_1" add variable named PATH with value "path1;path2;path3" and click Save. Confirm that PATH variable in profile is shown as list (list of 3 values and not as path1;path2;path3).
|
||||
- [ ] **[ADMIN: NO]** (L815) Edit PATH variable from "Test_profile_1". Try different options from ... menu (Delete, Move up, Move down, etc...). Click Save.
|
||||
- [ ] **[ADMIN: NO]** (L816) Apply "Test_profile_1". Open OS Environment variables window and confirm that profile is applied correctly - Path value and backup variable. Also, in "Applied variables" list check that Path variable has correct value: value of profile PATH concatenated to the end of SYSTEM Path.
|
||||
- [ ] **[ADMIN: NO]** (L819) Close the app and reopen it. Confirm that the state of the app is the same as before closing.
|
||||
- [ ] **[ADMIN: NO]** (L821) "Test_profile_1" should still be applied (if not apply it). Delete "Test_profile_1". Confirm that profile is unapplied (both in OS Environment variables window and "Applied variables" list).
|
||||
- [ ] **[ADMIN: NO]** (L822) Delete "Test_profile_2". Check profiles.json file and confirm that both profiles are gone.
|
||||
|
||||
35
.github/skills/powertoys-module-verification/references/release-checklist/file-locksmith.md
vendored
Normal file
35
.github/skills/powertoys-module-verification/references/release-checklist/file-locksmith.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# File Locksmith — PowerToys release checklist
|
||||
|
||||
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
|
||||
|
||||
## Legend
|
||||
|
||||
Each item is annotated with two metadata tags:
|
||||
|
||||
**Admin requirement**:
|
||||
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
|
||||
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
|
||||
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
|
||||
|
||||
**Clarity**:
|
||||
- (no marker) - clear, has explicit assert
|
||||
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
|
||||
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
|
||||
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
|
||||
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
|
||||
|
||||
---
|
||||
|
||||
## File Locksmith (10 items)
|
||||
|
||||
- [ ] **[ADMIN: COND]** (L641) Right-click the executable file, select "Unlock with File Locksmith" and verify it shows up. (2 entries will show, since the installer starts two processes)
|
||||
- [ ] **[ADMIN: COND]** (L642) End the tasks in File Locksmith UI and verify that closes the installer.
|
||||
- [ ] **[ADMIN: COND]** (L643) Start the installer executable again and press the Refresh button in File Locksmith UI. It should find new processes using the files.
|
||||
- [ ] **[ADMIN: COND]** (L644) Close the installer window and verify the processes are delisted from the File Locksmith UI. Close the window
|
||||
- [ ] **[ADMIN: COND]** (L646) Right click the directory where the executable is located, select "Unlock with File Locksmith" and verify it shows up.
|
||||
- [ ] **[ADMIN: COND]** (L647) Right click the drive where the executable is located, select "Unlock with File Locksmith" and verify it shows up. You can close the PowerToys installer now.
|
||||
- [ ] **[ADMIN: COND]** (L649) Right click "Program Files", select "Unlock with File Locksmith" and verify "PowerToys.exe" doesn't show up.
|
||||
- [ ] **[ADMIN: YES]** (L650) Press the File Locksmith "Restart as an administrator" button and verify "PowerToys.exe" shows up.
|
||||
- [ ] **[ADMIN: YES]** (L651) Right-click the drive where Windows is installed, select "Unlock with File Locksmith" and scroll down and up, verify File Locksmith doesn't crash with all those entries being shown. Repeat after clicking the File Locksmith "Restart as an administrator" button.
|
||||
- [ ] **[ADMIN: COND]** (L652) Disable File Locksmith in Settings and verify the context menu entry no longer appears.
|
||||
|
||||
43
.github/skills/powertoys-module-verification/references/release-checklist/image-resizer.md
vendored
Normal file
43
.github/skills/powertoys-module-verification/references/release-checklist/image-resizer.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Image Resizer — PowerToys release checklist
|
||||
|
||||
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
|
||||
|
||||
## Legend
|
||||
|
||||
Each item is annotated with two metadata tags:
|
||||
|
||||
**Admin requirement**:
|
||||
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
|
||||
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
|
||||
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
|
||||
|
||||
**Clarity**:
|
||||
- (no marker) - clear, has explicit assert
|
||||
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
|
||||
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
|
||||
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
|
||||
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
|
||||
|
||||
---
|
||||
|
||||
## Image Resizer (18 items)
|
||||
|
||||
- [ ] **[ADMIN: NO]** (L309) Disable the Image Resizer and check that `Resize with Image Resizer` is absent in the context menu
|
||||
- [ ] **[ADMIN: NO]** (L310) Enable the Image Resizer and check that `Resize with Image Resizer` is present in the context menu (both Win11 modern and old menus)
|
||||
- [ ] **[ADMIN: NO]** (L311) Remove one image size and add a custom image size. Open the Image Resize window from the context menu and verify changes are populated
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L312) Resize one image
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L313) Resize multiple images
|
||||
- [ ] **[ADMIN: NO]** (L314) Open image resizer to resize a .gif and verify "Gif files with animations may not be correctly resized." warning appears
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L316) Resize images with Fill option
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L317) Resize images with Fit option
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L318) Resize images with Stretch option
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L320) Resize using dimension Centimeters
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L321) Resize using dimension Inches
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L322) Resize using dimension Percents
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L323) Resize using dimension Pixels
|
||||
- [ ] **[ADMIN: NO]** (L325) Change Filename format to %1 - %2 - %3 - %4 - %5 - %6 and verify applied
|
||||
- [ ] **[ADMIN: NO]** (L326) Check Use original date modified and verify modified date not changed for resized
|
||||
- [ ] **[ADMIN: NO]** (L327) Check Make pictures smaller but not larger and verify smaller pictures not resized
|
||||
- [ ] **[ADMIN: NO]** (L328) Check Resize the original pictures (don't create copies) and verify original is resized
|
||||
- [ ] **[ADMIN: NO]** (L329) Uncheck Ignore the orientation and verify swapped W/H actually resizes if W!=H
|
||||
|
||||
14
.github/skills/powertoys-module-verification/references/release-checklist/index.md
vendored
Normal file
14
.github/skills/powertoys-module-verification/references/release-checklist/index.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Release checklist — per-module index
|
||||
|
||||
One file per module; a verification run loads only its module's file.
|
||||
|
||||
> **Scope:** only modules that have been verified end-to-end (with a sign-off report) are checked in here so far. The remaining modules' checklists will be added as each is verified.
|
||||
|
||||
| Module | Items | File |
|
||||
|---|---:|---|
|
||||
| Environment Variables | 20 | `environment-variables.md` |
|
||||
| File Locksmith | 10 | `file-locksmith.md` |
|
||||
| Image Resizer | 18 | `image-resizer.md` |
|
||||
| New+ | 9 | `new-plus.md` |
|
||||
| Peek | 18 | `peek.md` |
|
||||
| PowerRename | 18 | `power-rename.md` |
|
||||
34
.github/skills/powertoys-module-verification/references/release-checklist/new-plus.md
vendored
Normal file
34
.github/skills/powertoys-module-verification/references/release-checklist/new-plus.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# New+ — PowerToys release checklist
|
||||
|
||||
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
|
||||
|
||||
## Legend
|
||||
|
||||
Each item is annotated with two metadata tags:
|
||||
|
||||
**Admin requirement**:
|
||||
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
|
||||
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
|
||||
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
|
||||
|
||||
**Clarity**:
|
||||
- (no marker) - clear, has explicit assert
|
||||
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
|
||||
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
|
||||
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
|
||||
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
|
||||
|
||||
---
|
||||
|
||||
## New+ (9 items)
|
||||
|
||||
- [ ] **[ADMIN: NO]** (L969) Verify NewPlus menu is in Explorer context menu. (Windows 11 tier 1 context menu only. May need Explorer restart.)
|
||||
- [ ] **[ADMIN: NO]** (L971) Verify NewPlus menu is not in Explorer context menu.
|
||||
- [ ] **[ADMIN: NO]** (L973) Verify the folder is created and empty.
|
||||
- [ ] **[ADMIN: NO]** (L974) Copy a file to the templates folder, verify it's added to the New+ context menu and that if you select it the file is created.
|
||||
- [ ] **[ADMIN: NO]** (L975) Copy a folder with files inside to the templates folder, verify it's added to the New+ context menu and that if you select it the folder and files inside are created.
|
||||
- [ ] **[ADMIN: NO]** (L976) Delete all files and folders from inside the templates folder. Verify that no templates are available in the context menu.
|
||||
- [ ] **[ADMIN: NO]** (L977) Disable and re-Enable New+ while the templates folder is still empty. Verify the default templates were copied over and are available in the context menu.
|
||||
- [ ] **[ADMIN: NO]** (L979) Test the "Hide template filename extension" option in Settings.
|
||||
- [ ] **[ADMIN: NO]** (L980) Test the "Hide template filename starting digits, spaces and dots" option in Settings.
|
||||
|
||||
43
.github/skills/powertoys-module-verification/references/release-checklist/peek.md
vendored
Normal file
43
.github/skills/powertoys-module-verification/references/release-checklist/peek.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Peek — PowerToys release checklist
|
||||
|
||||
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
|
||||
|
||||
## Legend
|
||||
|
||||
Each item is annotated with two metadata tags:
|
||||
|
||||
**Admin requirement**:
|
||||
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
|
||||
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
|
||||
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
|
||||
|
||||
**Clarity**:
|
||||
- (no marker) - clear, has explicit assert
|
||||
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
|
||||
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
|
||||
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
|
||||
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
|
||||
|
||||
---
|
||||
|
||||
## Peek (18 items)
|
||||
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L697) Image
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L698) Text or dev file
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L699) Markdown file
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L700) PDF
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L701) HTML
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L702) Archive files (.zip, .tar, .rar)
|
||||
- [ ] **[ADMIN: NO]** (L703) Any other not mentioned file (.exe for example) to verify the unsupported file view is shown
|
||||
- [ ] **[ADMIN: NO]** (L706) Pin the window, switch between images of different size, verify the window stays at the same place and the same size.
|
||||
- [ ] **[ADMIN: NO]** (L707) Pin the window, close and reopen Peek, verify the new window is opened at the same place and the same size as before.
|
||||
- [ ] **[ADMIN: NO]** (L708) Unpin the window, switch to a different file, verify the window is moved to the default place.
|
||||
- [ ] **[ADMIN: NO]** (L709) Unpin the window, close and reopen Peek, verify the new window is opened on the default place.
|
||||
- [ ] **[ADMIN: NO]** (L712) By clicking a button.
|
||||
- [ ] **[ADMIN: NO]** (L713) By pressing enter.
|
||||
- [ ] **[ADMIN: NO]** (L716) Can use peek command to peek files
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L717) Peek can work without problem when a peek session is on
|
||||
- [ ] **[ADMIN: NO]** (L719) Switch between files in the folder using `LeftArrow` and `RightArrow`, verify you can switch between all files in the folder.
|
||||
- [ ] **[ADMIN: NO]** (L720) Open multiple files, verify you can switch only between selected files.
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-AMBIGUOUS]** (L721) Change the shortcut, verify the new one works.
|
||||
|
||||
43
.github/skills/powertoys-module-verification/references/release-checklist/power-rename.md
vendored
Normal file
43
.github/skills/powertoys-module-verification/references/release-checklist/power-rename.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# PowerRename — PowerToys release checklist
|
||||
|
||||
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
|
||||
|
||||
## Legend
|
||||
|
||||
Each item is annotated with two metadata tags:
|
||||
|
||||
**Admin requirement**:
|
||||
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
|
||||
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
|
||||
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
|
||||
|
||||
**Clarity**:
|
||||
- (no marker) - clear, has explicit assert
|
||||
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
|
||||
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
|
||||
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
|
||||
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
|
||||
|
||||
---
|
||||
|
||||
## PowerRename (18 items)
|
||||
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-AMBIGUOUS]** (L393) Check if disable and enable of the module works. (On Win11) Check if both old context menu and Win11 tier1 context menu items are present when module is enabled.
|
||||
- [ ] **[ADMIN: NO]** (L394) Check that with the `Show icon on context menu` icon is shown and vice versa.
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-AMBIGUOUS]** (L395) Check if `Appear only in extended context menu` works.
|
||||
- [ ] **[ADMIN: NO]** (L396) Enable/disable autocomplete.
|
||||
- [ ] **[ADMIN: NO]** (L397) Enable/disable `Show values from last use`.
|
||||
- [ ] **[ADMIN: NO]** (L399) Make Uppercase/Lowercase/Titlecase (could be selected only one at the time)
|
||||
- [ ] **[ADMIN: NO]** (L400) Exclude Folders/Files/Subfolder Items (could be selected several)
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L401) Item Name/Extension Only (one at the time)
|
||||
- [ ] **[ADMIN: NO]** (L402) Enumerate Items. Test advanced enumeration using different values for every field ${start=10,increment=2,padding=4}.
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L403) Case Sensitive
|
||||
- [ ] **[ADMIN: NO]** (L404) Match All Occurrences. If checked, all matches of text in the `Search` field will be replaced with the Replace text. Otherwise, only the first instance of the `Search` for text in the file name will be replaced (left to right).
|
||||
- [ ] **[ADMIN: NO]** (L406) Search with an expression (e.g. `(.*).png`)
|
||||
- [ ] **[ADMIN: NO]** (L407) Replace with an expression (e.g. `foo_$1.png`)
|
||||
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L408) Replace using file creation date and time (e.g. `$hh-$mm-$ss-$fff` `$DD_$MMMM_$YYYY`)
|
||||
- [ ] **[ADMIN: NO]** (L409) Turn on `Use Boost library` and test with Perl Regular Expression Syntax (e.g. `(?<=t)est`)
|
||||
- [ ] **[ADMIN: NO]** (L411) In the `preview` window uncheck some items to exclude them from renaming.
|
||||
- [ ] **[ADMIN: NO]** (L412) Use the **Filter** (funnel) button above the file list → choose "Only show files that will be renamed" / "Show all files" to filter the preview.
|
||||
- [ ] **[ADMIN: NO]** (L413) Use the **Select/deselect all** checkbox above the file list to toggle all rows checked/unchecked.
|
||||
|
||||
159
.github/skills/powertoys-module-verification/references/reporting-format.md
vendored
Normal file
159
.github/skills/powertoys-module-verification/references/reporting-format.md
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
# Reporting format
|
||||
|
||||
This doc defines the **required** report shape for every per-module verification run. Modeled on `PR-validation\Round1\PR-47211-validation\report.md` style — table-driven, reproducible, no prose narratives.
|
||||
|
||||
## §A — Per-item table (one per checklist item)
|
||||
|
||||
```markdown
|
||||
## Item L<line_num> — <verbatim description from the module's checklist> — **<PASS|FAIL|BLOCKED>** <emoji>
|
||||
|
||||
**Admin**: <NO|COND|YES> | **Clarity**: <CLEAR|VAGUE-*|REWRITTEN> | **Category**: <PASS: verification method (free text) · FAIL: cause = product | checklist-stale | checklist-ambiguous · BLOCKED: a BLK-* reason>
|
||||
|
||||
### Verification steps performed
|
||||
|
||||
| # | Step | winapp / probe commands | Evidence / result |
|
||||
|---|---|---|---|
|
||||
| 1 | <what step 1 does> | `<exact command>`<br>`<another command if multiple>` | <what you observed; reference artifact filename> |
|
||||
| 2 | <what step 2 does> | `<command>` | <evidence>; screenshot: `artifacts/L<line>/step-02-<name>.png` |
|
||||
| 3 | ... | ... | ... |
|
||||
|
||||
### Artifacts produced
|
||||
- `artifacts/L<line>/step-01-<name>.png` — <one-line description>
|
||||
- `artifacts/L<line>/step-02-<name>.txt` — full inspect dump
|
||||
- ...
|
||||
|
||||
### Verdict reasoning
|
||||
- ✅ <assertion 1 that PASSed, with reference to the line of code / settings key / log line that proves it>
|
||||
- ✅ <assertion 2>
|
||||
- ❌ <if BLOCKED, the specific obstacle: "BLK-HARDWARE because MWB needs 2 physical PCs; this session has 1 ([System.Windows.Forms.Screen]::AllScreens.Count = 1)">
|
||||
|
||||
### Caveats (optional)
|
||||
- <Any deviation from the user-documented flow, e.g. "Tested via settings.json write rather than UI checkbox because SelectionItemPattern.Select clobbers other selections in ListView.">
|
||||
```
|
||||
|
||||
## §B — Top-of-report summary (write LAST, after all per-item tables)
|
||||
|
||||
```markdown
|
||||
# <Module> verification report — <YYYY-MM-DD HH:MM>
|
||||
|
||||
## Summary
|
||||
- **PASS**: <n> · **FAIL (product)**: <n> · **FAIL (checklist)**: <n> · **BLOCKED**: <n> · **Total**: <n> · **PASS%**: <n>
|
||||
- **Top blocker categories**: <category>: <count>, <category>: <count>, ...
|
||||
- **Items needing follow-up**: L<line> (<reason>), L<line> (<reason>), ...
|
||||
- **State mutations performed + restored**: <count> settings.json edits restored, <count> registry keys removed, <count> fixture files deleted
|
||||
|
||||
## Pre-flight
|
||||
- IsAdmin: <true|false>
|
||||
- PT runner: PID=<n> Elevated=<true|false>
|
||||
- <Module> settings file: <path> (exists=<true|false>)
|
||||
- Interactive desktop: ForegroundOk=<true|false> ShellComOk=<true|false>
|
||||
|
||||
## Items
|
||||
<all per-item tables here, in line_num order>
|
||||
|
||||
## Cleanup performed
|
||||
- <list of every restore action taken>
|
||||
|
||||
## Retrospective (self-reflection on the run — write LAST)
|
||||
<Per §G. If the whole run was frictionless, write exactly: **Everything was smooth — no friction encountered.**>
|
||||
```
|
||||
|
||||
## §C — Required rules for step tables
|
||||
|
||||
1. **Every `winapp ui ...` command goes in the "winapp / probe commands" cell, verbatim, in backticks**, including `-w <hwnd>` / `-a <appId>` arguments and full selector strings. Reviewers will paste these into their own shell to reproduce.
|
||||
2. **Every screenshot path goes in the "Evidence" cell** of the step that produced it, formatted as `screenshot: artifacts/L<line>/step-NN-<name>.png`. Never embed screenshots as `` in the table body (breaks GitHub markdown rendering inside cells); just give the path.
|
||||
3. **If a step has multiple commands**, separate them in the same cell with `<br>` so they render as one cell with multiple lines.
|
||||
4. **PowerShell scriptlets > 3 lines**: write them to a separate `.ps1` in the artifacts folder and reference as ``script: `artifacts/L<line>/step-NN.ps1` `` in the cell. Keep the table cell to 1-3 lines.
|
||||
5. **`—` (em dash) is allowed for non-CLI steps** like "Read sign-off entry + diff", "Create validation folder", "Cleanup notepad". Don't fabricate a command for steps that were purely cognitive or file-system level.
|
||||
6. **Numbered steps must be contiguous** (1, 2, 3, ...). Don't skip numbers.
|
||||
7. **At least one screenshot per PASS item if the item is a user-visible behavioral test**. Schema-only assertions (settings.json key check) don't need screenshots; behavioral tests (popup shown, dialog appeared, theme switched) do.
|
||||
|
||||
## §D — Reporting style
|
||||
|
||||
- Be specific. "Verified via UIA inspect returned `itm-calculator-XXXX`" beats "verified UIA".
|
||||
- Include exact UIA selectors, log line text, settings.json keys, and screenshot filenames so the user can audit.
|
||||
- For BLOCKED items, the 1-sentence reason should name **what specifically blocks**, e.g.:
|
||||
- "BLK-HARDWARE: requires 2nd monitor; session has 1 (verified via `[System.Windows.Forms.Screen]::AllScreens.Count`)."
|
||||
- "BLK-DRAG-REQUIRED: synthetic mouse drag insufficient for FZ snap-and-drag; needs real cursor motion."
|
||||
- "BLK-ENV: SendInput returned ACCESS_DENIED (5) because Session $agentSession ≠ console Session $consoleSession. See `references/environment-setup.md`."
|
||||
- "BLK-EXTERNAL-APP: requires real OpenAI API key; no key provisioned in test env."
|
||||
|
||||
## §E — Reporting anti-patterns (extra strict)
|
||||
|
||||
- Do NOT collapse multiple probe commands into a single English sentence like "verified via UIA". List every `winapp ui ...` command verbatim in a step row.
|
||||
- Do NOT skip the step table for "trivial" items. Even a 1-step item (e.g. "Get-CmdPalSettings shows EnableDock=true") gets a 1-row table.
|
||||
- Do NOT write screenshot references as `` inside table cells (GitHub renders markdown images poorly in cells). Write them as plain text path: `screenshot: artifacts/L<line>/step-NN-<name>.png`.
|
||||
- Do NOT use "the test passed" as a screenshot caption — describe what's visible (e.g. "Settings page with FZ template grid showing 7 templates").
|
||||
- Do NOT reference screenshots that you didn't actually capture. The final wrap-up `Test-Path` loop (see `references/pre-flight.md` §Final wrap-up step 3) will catch missing files; failing that check means the report is invalid.
|
||||
- Do NOT cite source code line numbers (e.g. `CharacterMappings.cs:273`) without having actually read that line. If you cite source, the path must be real and the line number must contain what you claim.
|
||||
|
||||
## §F — Example item (reference: PR-47211 validation report style)
|
||||
|
||||
```markdown
|
||||
## Item L455 — Activate Quick Accent (left Alt + arrow key) on a character, verify accents popup — **PASS** ✅
|
||||
|
||||
**Admin**: NO | **Clarity**: CLEAR | **Category**: drove full UIA flow + asserted accents popup
|
||||
|
||||
### Verification steps performed
|
||||
|
||||
| # | Step | winapp / probe commands | Evidence / result |
|
||||
|---|---|---|---|
|
||||
| 1 | Locate Settings window | `winapp ui list-windows --json` | `hwnd=263304`, `PowerToys.Settings` PID 31740 |
|
||||
| 2 | Navigate to Quick Accent + expand language flyout | `winapp ui invoke QuickAccentNavItem -w 263304`<br>`winapp ui invoke btn-choosecharacter-1c4d -w 263304` | Page loaded; flyout expanded |
|
||||
| 3 | Enumerate language list + screenshot | `winapp ui inspect btn-choosecharacter-1c4d -w 263304 --depth 5`<br>`winapp ui screenshot -w 263304 -o "artifacts/L455/step-03-language-list.png"` | 38 spoken + 6 special languages, alphabetic. screenshot: `artifacts/L455/step-03-language-list.png` |
|
||||
| 4 | Single-language (French) popup test | `winapp ui invoke itm-french-1cac -w 263304`<br>`winapp ui inspect characters -w <popupHwnd> --depth 3`<br>`winapp ui screenshot -w <popupHwnd> -o "artifacts/L455/step-04-popup-FR-E.png"` | Popup chars for **E** = `é è ê ë €` (5), matches `FR.VK_E` in `CharacterMappings.cs:273`. screenshot: `artifacts/L455/step-04-popup-FR-E.png` |
|
||||
| 5 | Restore baseline | — | settings.json reverted to `selected_lang="ALL"` |
|
||||
|
||||
### Artifacts produced
|
||||
- `artifacts/L455/step-03-language-list.png` — Settings page with expanded language flyout
|
||||
- `artifacts/L455/step-03-language-list.txt` — full UIA inspect dump of the list
|
||||
- `artifacts/L455/step-04-popup-FR-E.png` — Popup with French only: `é è ê ë €`
|
||||
|
||||
### Verdict reasoning
|
||||
- ✅ Popup characters match `CharacterMappings.cs` entries exactly (5/5 for FR.VK_E)
|
||||
- ✅ Popup appeared within 500ms of hold-A; no crash
|
||||
- ✅ Language list ordering is alphabetic by localized name
|
||||
```
|
||||
|
||||
## §G — Retrospective (self-reflection)
|
||||
|
||||
After the run, reflect on the **process** (not the product) so the skill itself gets better over time. **If nothing slowed you down, write exactly one line: `Everything was smooth — no friction encountered.`** Otherwise, list each friction as a row and assign a source + severity.
|
||||
|
||||
```markdown
|
||||
## Retrospective
|
||||
|
||||
| # | Friction (what slowed you / what was wrong) | Source | Severity | Cost | Suggested fix |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | <concrete description — what you expected vs what happened> | <one source tag below> | <HIGH/MED/LOW> | <~min wasted · N attempts> | <the doc line / helper function / tool behavior to change> |
|
||||
```
|
||||
|
||||
**Source** — classify each friction into exactly one bucket so the right owner can fix it:
|
||||
|
||||
| Source tag | Meaning |
|
||||
|---|---|
|
||||
| `SKILL-UNCLEAR` | This skill's `SKILL.md` / `references/pre-flight.md` / module profile guidance was missing, ambiguous, or wrong. |
|
||||
| `WINAPP-TOOL-BUG` | The `winapp` CLI itself misbehaved (crash, wrong output, flag not honored) — a product defect in the tool. |
|
||||
| `WINAPP-DOC-UNCLEAR` | `references/winapp-ui-testing.md` was unclear/incorrect about how to use the tool (the tool worked; the docs misled you). |
|
||||
| `HELPER-FLAW` | A shipped `scripts/*.ps1` had a logic bug, bad default, or wrong assumption. Name the function. |
|
||||
| `PT-PRODUCT` | A PowerToys behavior/quirk made driving hard (distinct from a product **FAIL** — this is friction, not a checklist failure). |
|
||||
| `CHECKLIST` | The checklist item itself was wrong/stale/ambiguous (e.g. describes a renamed or removed control). Note: this usually *also* produces a `FAIL (cause: checklist-*)` verdict on the item; log it here too so the checklist owner sees it as a process-improvement signal. |
|
||||
| `ENVIRONMENT` | RDP/session/desktop/elevation friction not already covered by `references/environment-setup.md`. |
|
||||
|
||||
**Severity** — judge by *impact on future agents*, not just yourself:
|
||||
- **HIGH** — most agents will hit it; blocks progress or wastes >10 min, or you needed a non-obvious workaround.
|
||||
- **MED** — many agents may hit it; cost a few minutes or 2-3 retries; workaround exists once known.
|
||||
- **LOW** — edge case or cosmetic; <1 min; noted for completeness.
|
||||
|
||||
**Cost** — be concrete: approximate minutes wasted **and** number of attempts (e.g. `~8 min · 3 attempts`). This is the raw signal for prioritizing skill fixes.
|
||||
|
||||
**Suggested fix** — point at the specific artifact to change: a doc line/section, a helper function name, or a `winapp` behavior to file. Vague reflections ("docs could be clearer") are not actionable — cite the line.
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
## Retrospective
|
||||
|
||||
| # | Friction | Source | Severity | Cost | Suggested fix |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `winapp ui inspect --depth 7 -w $hwnd` threw "Cannot bind argument" until I moved `-w` after `--depth`. | `WINAPP-TOOL-BUG` | MED | ~6 min · 3 attempts | Already noted in pitfall #14, but the tool should parse flag order — file against winapp. |
|
||||
| 2 | SKILL.md §2.A says "wait 4s debounce" but PowerRename needed a full `Restart-PtRunner`; the module-owned-file note (pitfall #18) wasn't cross-linked from §2.A. | `SKILL-UNCLEAR` | HIGH | ~12 min · 4 attempts | Add an explicit "shell-ext modules → see pitfall #18" pointer inside §2.A. |
|
||||
```
|
||||
531
.github/skills/powertoys-module-verification/references/winapp-ui-testing.md
vendored
Normal file
531
.github/skills/powertoys-module-verification/references/winapp-ui-testing.md
vendored
Normal file
@@ -0,0 +1,531 @@
|
||||
# WinUI UI-testing mechanics (winapp ui)
|
||||
|
||||
> **Provenance:** Adapted from the `winui-ui-testing` skill in [microsoft/win-dev-skills](https://github.com/microsoft/win-dev-skills) (MIT, © Microsoft Corporation and Contributors), with PowerToys-specific edits. This is a **reference doc** for the `powertoys-module-verification` skill — it is intentionally not a standalone skill (no frontmatter), so it is not separately discovered.
|
||||
|
||||
Automated UI testing for WinUI 3 apps — generate a batch test script, run all tests in one pass, read results. Covers element assertions, interactions, value checking (TextBox, ComboBox, ToggleSwitch), file pickers, flyouts, dialogs, persistence, and accessibility audits.
|
||||
|
||||
### Approach
|
||||
|
||||
The goal of this skill is to validate UI and app functionality automatically, without manual interaction, by exercising the app's UI elements, verifying their state, and asserting that the app behaves as expected under test conditions.
|
||||
|
||||
There are two main approaches:
|
||||
1. Interactive exploration — manually run the app, use `winapp ui <command>` to explore the UI tree, find AutomationIds, verify element properties, and test functionality interactively. This is useful for discovery, but slow and expensive if repeated for every test iteration.
|
||||
2. Scripted batch testing — generate a `ui-tests.ps1` script that exercises all UI elements and asserts expected behavior in one pass. This allows you to run the tests automatically, capture results, and iterate quickly without manually interacting with the app each time.
|
||||
|
||||
Unless the user asked for interactive exploration, or you are unfamiliar with the code/app or need to explore the UI tree to discover AutomationIds for hidden or dynamically generated elements (flyouts, dialogs, lazy-loaded content), **prefer scripted batch testing** — it is faster, repeatable, and produces a record of pass/fail results that can be reviewed and acted on.
|
||||
|
||||
### `winapp ui` Verbs
|
||||
|
||||
`status`, `inspect`, `search`, `get-property`, `get-value`, `screenshot`, `invoke`, `click`, `set-value`, `focus`, `scroll`, `scroll-into-view`, `wait-for`, `list-windows`, `get-focused`. Run `winapp ui --cli-schema` for the complete command structure as JSON, or `winapp ui <verb> --help` for any single verb.
|
||||
|
||||
### Step 1: Use the Running App
|
||||
|
||||
If the app is already running, use its PID. **Do NOT relaunch** — use the PID already captured from the build step. If the app is not running, build and launch it using the guidance in the winui-dev-workflow skill.
|
||||
|
||||
### Step 2: Write the Test Script
|
||||
|
||||
**If you wrote the code:** Skip inspect — you already know all the AutomationIds and control structure from the XAML and code-behind. Write tests directly from that knowledge. Inspect misses popups, flyouts, dialogs, and lazy-loaded content anyway.
|
||||
|
||||
**If you're verifying code you didn't write:** Run inspect first to discover the UI:
|
||||
```powershell
|
||||
winapp ui inspect -a <PID> --interactive
|
||||
```
|
||||
Then read the XAML files to find AutomationIds that aren't currently visible (flyout items, dialog buttons, secondary pages).
|
||||
|
||||
Create a `ui-tests.ps1` file that tests all the app's requirements in one pass:
|
||||
|
||||
```powershell
|
||||
# ui-tests.ps1
|
||||
param([Parameter(Mandatory)][int]$AppPid)
|
||||
# NOTE: Do NOT name the parameter $Pid — it's read-only in PowerShell
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$pass = 0; $fail = 0; $results = @()
|
||||
|
||||
# Get main window HWND (avoids PopupHost interference with JSON parsing)
|
||||
$windows = winapp ui list-windows -a $AppPid --json 2>$null | ConvertFrom-Json
|
||||
$hwnd = ($windows | Where-Object { $_.title -ne "PopupHost" } | Select-Object -First 1).hwnd
|
||||
|
||||
function Test-UI {
|
||||
param([string]$Name, [scriptblock]$Script)
|
||||
# IMPORTANT: Inside $Script, use 'throw' to signal failure — NOT 'exit 1'
|
||||
# (exit terminates the entire script, not just the test)
|
||||
try {
|
||||
$output = & $Script 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$script:pass++; $script:results += @{ name = $Name; status = "PASS" }
|
||||
} else {
|
||||
$script:fail++; $script:results += @{ name = $Name; status = "FAIL"; detail = "$output" }
|
||||
}
|
||||
} catch {
|
||||
$script:fail++; $script:results += @{ name = $Name; status = "FAIL"; detail = "$_" }
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Element Existence ───
|
||||
Test-UI "NavHome exists" { winapp ui wait-for "NavHome" -a $AppPid -t 3000 }
|
||||
Test-UI "NavSettings exists" { winapp ui wait-for "NavSettings" -a $AppPid -t 3000 }
|
||||
|
||||
# ─── Navigation ───
|
||||
Test-UI "Navigate to Settings" { winapp ui invoke "NavSettings" -a $AppPid }
|
||||
Test-UI "Settings page loaded" { winapp ui wait-for "TxtUserName" -a $AppPid -t 3000 }
|
||||
|
||||
# ─── Interactions ───
|
||||
Test-UI "Set username" { winapp ui set-value "TxtUserName" "TestUser" -a $AppPid }
|
||||
Test-UI "Click Save" { winapp ui invoke "BtnSave" -a $AppPid } # commits the TextBox binding
|
||||
Test-UI "Username value set" {
|
||||
winapp ui wait-for "TxtUserName" -a $AppPid --value "TestUser" -t 2000
|
||||
}
|
||||
|
||||
# ─── Value assertions for different control types ───
|
||||
Test-UI "Theme is System default" {
|
||||
winapp ui wait-for "CmbTheme" -a $AppPid --value "System default" -t 2000
|
||||
}
|
||||
Test-UI "Logging is off" {
|
||||
winapp ui wait-for "TglLogging" -a $AppPid --value "Off" -t 2000
|
||||
}
|
||||
|
||||
# ─── Accessibility Audit ───
|
||||
# Only audit controls in the app's main window (exclude OS picker/popup controls)
|
||||
$allElements = (winapp ui inspect -a $AppPid --interactive --json 2>$null | ConvertFrom-Json).elements
|
||||
$appElements = @($allElements | Where-Object {
|
||||
$_.type -match 'Button|TextBox|ComboBox|CheckBox|ToggleSwitch|TabItem|Edit' -and
|
||||
$_.name -notmatch 'Minimize|Maximize|Close|System' -and # window chrome
|
||||
$_.className -notmatch 'PickerHost|#32770|CabinetWClass' # OS dialogs
|
||||
})
|
||||
$missingId = @($appElements | Where-Object { -not $_.automationId })
|
||||
if ($missingId.Count -eq 0) {
|
||||
$pass++; $results += @{ name = "All app controls have AutomationId"; status = "PASS" }
|
||||
} else {
|
||||
$fail++
|
||||
$names = ($missingId | ForEach-Object { "$($_.type) '$($_.name)'" }) -join ", "
|
||||
$results += @{ name = "AutomationId coverage"; status = "FAIL"; detail = "Missing: $names" }
|
||||
}
|
||||
|
||||
# ─── State Screenshots (capture each meaningful state for visual review) ───
|
||||
New-Item -ItemType Directory -Force -Path "screenshots" | Out-Null
|
||||
winapp ui screenshot -a $AppPid -o "screenshots/01-initial.png" 2>$null
|
||||
# ...take more screenshots after key interactions above (mode switches, dialogs opened, etc.)
|
||||
|
||||
# ─── Final Screenshot ───
|
||||
winapp ui screenshot -a $AppPid -o "test-screenshot.png" 2>$null
|
||||
|
||||
# ─── Results ───
|
||||
Write-Host "`nPassed: $pass | Failed: $fail"
|
||||
$results | Where-Object { $_.status -eq "FAIL" } | ForEach-Object {
|
||||
Write-Host " FAIL: $($_.name) — $($_.detail)" -ForegroundColor Red
|
||||
}
|
||||
$results | ConvertTo-Json | Out-File "test-results.json"
|
||||
if ($fail -gt 0) { exit 1 } else { exit 0 }
|
||||
```
|
||||
|
||||
### What to Test
|
||||
|
||||
Write tests for **every requirement** from the user's prompt:
|
||||
|
||||
| Requirement type | Test approach |
|
||||
|---|---|
|
||||
| "Has a button that does X" | `search` to verify exists, `invoke` to click, `wait-for --value` to check result |
|
||||
| "Text field shows value" | `wait-for "TxtName" --value "expected"` — works for TextBox, TextBlock, labels |
|
||||
| "Status bar contains text" | `wait-for "StatusBar" --value "words" --contains` — substring match for dynamic content |
|
||||
| "Dropdown is set to X" | `wait-for "CmbTheme" --value "Dark"` — reads the selected item automatically |
|
||||
| "Toggle is on/off" | `wait-for "TglFeature" --value "On"` — reads the toggle state |
|
||||
| "Navigation between pages" | `invoke` nav item, `wait-for` a page-specific element to appear |
|
||||
| "Open file dialog" | `invoke` trigger, `list-windows` to find picker HWND, interact with `-w` |
|
||||
| "Save file dialog" | Same as open — find picker with `list-windows`, `set-value` filename, `invoke` Save |
|
||||
| "Right-click context menu" | `click --right` on element, `invoke` the flyout MenuItem |
|
||||
| "Confirmation dialog" | `invoke` trigger, `search` for dialog buttons, `invoke` Primary/Secondary/Close |
|
||||
| "Data persists" | Set values, `invoke` a button (to commit bindings), verify data file on disk (`Get-Content` + `ConvertFrom-Json`) |
|
||||
| "All controls accessible" | `inspect --interactive --json` + check all have AutomationId |
|
||||
|
||||
### Step 3: Run and Read Results
|
||||
|
||||
```powershell
|
||||
.\ui-tests.ps1 -AppPid <PID>
|
||||
```
|
||||
|
||||
Read `test-results.json` for structured pass/fail. Only fix code if tests fail.
|
||||
|
||||
### Step 3.5: Look at the Screenshots
|
||||
|
||||
UIA assertions don't see clipping, overlap, wrong theming, or controls bleeding past their container — UIA returns `PASS` while the app is visually broken. **Capture screenshots with `winapp ui screenshot` and view each PNG.**
|
||||
|
||||
Capture the initial state and any state after a major interaction (the State Screenshots block in the script template above handles this).
|
||||
|
||||
**Visual checklist — fail the run if any item is `no`:**
|
||||
- [ ] No unintended scrollbars
|
||||
- [ ] No text ending in `…` that shouldn't be
|
||||
- [ ] Hero elements fully visible (not sliced)
|
||||
- [ ] Right-edge controls fully visible
|
||||
- [ ] No overlapping rows
|
||||
- [ ] Content uses the available width — no asymmetric dead zones (e.g. content pinned to one edge leaving empty space on the other)
|
||||
- [ ] Spacing intentional — not cramped, not unintentionally vast
|
||||
- [ ] Theming matches the user's ask (Light/Dark/HighContrast if relevant)
|
||||
- [ ] Focus/hover/error states render if tested
|
||||
|
||||
If the checklist fails, it's a bug — fix before declaring done. Window too small → grow per `winui-design` Step 4.
|
||||
|
||||
### Step 4: Fix and Rerun (if the user asked for it)
|
||||
|
||||
If tests fail:
|
||||
1. Read the failure details from `test-results.json`
|
||||
2. Batch-fix all issues in one pass
|
||||
3. Rebuild with `.\BuildAndRun.ps1` (blocking mode — shows crash info if the fix broke something)
|
||||
4. Rerun `.\ui-tests.ps1 -AppPid <PID>` (parse PID from the `launched (PID: XXXXX)` output)
|
||||
|
||||
**Maximum 2 fix-and-rerun cycles.** If the same tests keep failing after 2 cycles, report them as known issues and move on — do not keep iterating.
|
||||
|
||||
### Assertion Reference
|
||||
|
||||
Use `wait-for --value` as the primary assertion — it uses a smart fallback chain that reads the right value for any control type:
|
||||
|
||||
| Control type | `--value` reads from | Example |
|
||||
|---|---|---|
|
||||
| TextBlock / Label | Name property | `wait-for "LblTitle" --value "Home"` |
|
||||
| TextBox / NumberBox | ValuePattern | `wait-for "TxtName" --value "John"` |
|
||||
| RichEditBox | TextPattern | `wait-for "Editor" --value "Hello"` |
|
||||
| ComboBox | Selected item (SelectionPattern) | `wait-for "CmbTheme" --value "Dark"` |
|
||||
| ToggleSwitch | Toggle state (On/Off) | `wait-for "TglDark" --value "On"` |
|
||||
| CheckBox | Toggle state (On/Off) | `wait-for "ChkAgree" --value "On"` |
|
||||
|
||||
**Full assertion commands:**
|
||||
|
||||
| Assertion | Command |
|
||||
|---|---|
|
||||
| Element exists | `winapp ui wait-for "Id" -a PID -t 3000` |
|
||||
| Element has exact value | `winapp ui wait-for "Id" -a PID --value "expected" -t 3000` |
|
||||
| Value contains text | `winapp ui wait-for "Id" -a PID --value "words" --contains -t 3000` |
|
||||
| Element gone | `winapp ui wait-for "Id" -a PID --gone -t 3000` |
|
||||
| Specific property | `winapp ui wait-for "Id" -a PID -p IsEnabled --value "True" -t 3000` |
|
||||
| Button clickable | `winapp ui invoke "Id" -a PID` (exit code 0) |
|
||||
| Set then verify | `winapp ui set-value "Id" "text" -a PID` then `wait-for --value` |
|
||||
| Screenshot | `winapp ui screenshot -a PID -o path.png` |
|
||||
| Dialog appeared | `winapp ui list-windows -a PID --json` (check window count) |
|
||||
| Right-click menu | `winapp ui click "Id" -a PID --right` then `wait-for` menu item |
|
||||
| Read raw property | `winapp ui get-property "Id" -a PID -p IsEnabled --json` |
|
||||
| Read current value (no wait) | `(winapp ui get-value "Id" -a PID --json \| ConvertFrom-Json).text` — always pass `--json` when capturing into a variable (plain stdout can include advisory text like "Auto-selected HWND … from N windows"); otherwise prefer `wait-for --value` |
|
||||
| Scroll item into view | `winapp ui scroll-into-view "Id" -a PID` — call before `wait-for` on virtualized ListView/repeater items below the fold |
|
||||
| Set keyboard focus | `winapp ui focus "Id" -a PID` — cleaner than clicking another control to trigger a TextBox `LostFocus` commit |
|
||||
|
||||
### Testing File Pickers
|
||||
|
||||
File/folder pickers (FileOpenPicker, FileSavePicker, FolderPicker) run in a separate `PickerHost` process but are fully interactable. The picker appears as an owned dialog window.
|
||||
|
||||
```powershell
|
||||
# 1. Trigger the picker
|
||||
winapp ui invoke "BtnOpenFile" -a $AppPid
|
||||
|
||||
# 2. Find the picker window (it's a dialog owned by the app window)
|
||||
Start-Sleep 1
|
||||
$allWindows = winapp ui list-windows -a $AppPid --json 2>$null | ConvertFrom-Json
|
||||
$picker = $allWindows | Where-Object { $_.title -match "Open|Save" }
|
||||
$pickerHwnd = $picker.hwnd
|
||||
|
||||
# 3. Interact with the picker using -w <HWND>
|
||||
# Type a filename:
|
||||
winapp ui set-value "FileNameControlHost" "test.txt" -w $pickerHwnd
|
||||
# Click Open/Save:
|
||||
winapp ui invoke "Open" -w $pickerHwnd # or "Save", "Cancel"
|
||||
# Or cancel:
|
||||
winapp ui invoke "Cancel" -w $pickerHwnd
|
||||
|
||||
# 4. Verify the app processed the file
|
||||
winapp ui wait-for "StatusBar" -a $AppPid -p Name --value "opened" -t 3000
|
||||
```
|
||||
|
||||
**Tip:** Use `winapp ui inspect -w <pickerHwnd> --interactive` to discover the picker's controls — they include the folder tree, file list, filename textbox, and Open/Cancel buttons.
|
||||
|
||||
### Testing Context Menus and Flyouts
|
||||
|
||||
MenuFlyouts and ContextFlyouts are fully testable. They appear in the UI automation tree when open.
|
||||
|
||||
```powershell
|
||||
# 1. Right-click to open a ContextFlyout
|
||||
winapp ui click "LstItems" -a $AppPid --right
|
||||
Start-Sleep 0.5
|
||||
|
||||
# 2. The flyout MenuItems appear in the tree immediately
|
||||
# Find them with inspect or search:
|
||||
winapp ui inspect -a $AppPid --interactive # shows MnuCopy, MnuDelete, etc.
|
||||
|
||||
# 3. Click a flyout item
|
||||
winapp ui invoke "MnuCopy" -a $AppPid
|
||||
|
||||
# 4. Verify the action
|
||||
winapp ui wait-for "StatusText" -a $AppPid -p Name --value "Copied" -t 2000
|
||||
```
|
||||
|
||||
**For MenuBar flyouts** (File, Edit, View menus):
|
||||
```powershell
|
||||
# Click the menu header to open
|
||||
winapp ui invoke "FileMenu" -a $AppPid
|
||||
Start-Sleep 0.5
|
||||
# Click the sub-item
|
||||
winapp ui invoke "MenuSaveAs" -a $AppPid
|
||||
```
|
||||
|
||||
### Testing ContentDialogs
|
||||
|
||||
ContentDialogs are in-app controls (same window) — they appear directly in the UI tree when shown.
|
||||
|
||||
```powershell
|
||||
# 1. Trigger the dialog
|
||||
winapp ui invoke "BtnDelete" -a $AppPid
|
||||
Start-Sleep 0.5
|
||||
|
||||
# 2. The dialog buttons appear in the tree
|
||||
# For a standard confirmation dialog:
|
||||
winapp ui search "Primary" -a $AppPid --json # finds the primary button
|
||||
winapp ui invoke "Primary" -a $AppPid # click "Yes"/"Delete"/"Save"
|
||||
# Or:
|
||||
winapp ui invoke "Secondary" -a $AppPid # click "No"/"Don't Save"
|
||||
winapp ui invoke "Close" -a $AppPid # click "Cancel"
|
||||
|
||||
# 3. Wait for dialog to dismiss
|
||||
winapp ui wait-for "Primary" -a $AppPid --gone -t 3000
|
||||
```
|
||||
|
||||
**Tip:** ContentDialog buttons often don't have custom AutomationIds — use `inspect` to find the actual selector (slug or text match).
|
||||
|
||||
### Key Gotchas
|
||||
|
||||
- **`set-value` does NOT commit default TextBox bindings** — WinUI 3 `x:Bind TwoWay` on TextBox.Text updates the ViewModel on `LostFocus` by default. UIA `set-value` changes the text but doesn't trigger focus events. **Fix:** apps should use `UpdateSourceTrigger=PropertyChanged` on TextBox bindings (see design skill). If the app doesn't, `invoke` a button or `click` another element after `set-value` to trigger `LostFocus`.
|
||||
- **Verify persistence via the data file, not UI relaunch** — killing and relaunching a packaged app from a test script is fragile (MSIX registration timing, PID issues). Instead, check the data file on disk: `Get-Content $dataFile | ConvertFrom-Json` and verify expected values.
|
||||
- **Use `$AppPid` not `$Pid`** — `$Pid` is a read-only automatic variable in PowerShell
|
||||
- **Use `--value` without `-p`** — it auto-detects the right UIA pattern (TextPattern → ValuePattern → TogglePattern → SelectionPattern → Name). Only use `-p PropertyName --value` when you need a specific property like `IsEnabled`
|
||||
- **File pickers need `-w <HWND>`** — they run in a separate PickerHost process, so `-a PID` won't find them. Use `list-windows` to discover the picker HWND first
|
||||
- **Flyouts need a short `Start-Sleep`** after triggering — the menu items appear in the tree asynchronously
|
||||
|
||||
### CRITICAL — `invoke` vs `click`: choose the right verb
|
||||
|
||||
**`winapp ui invoke <sel>`** dispatches through UIA's **`InvokePattern` via COM IPC**:
|
||||
- ✅ Bypasses Windows UIPI (User Interface Privilege Isolation)
|
||||
- ✅ Works even when your test runs elevated and the target is non-elevated AppX
|
||||
- ✅ Does NOT steal foreground / does NOT trigger focus-loss handlers
|
||||
- ✅ Works on Buttons, ListItems, ToggleSwitches, CheckBoxes — anything that exposes `InvokePattern` or `TogglePattern`
|
||||
- ❌ Does NOT work on elements without an UIA action pattern (plain Grid, Text, Pane) — error message says "does not support any invoke pattern"
|
||||
|
||||
**`winapp ui click <sel>`** uses Win32 **`SendInput`** under the hood:
|
||||
- ❌ **BLOCKED by UIPI** when source is elevated and target is non-elevated (or any AppX) — error: `SendInput failed — the target window may be elevated`
|
||||
- ❌ Triggers foreground change → can dismiss popups, dialogs, AppX windows that hide on deactivation
|
||||
- ✅ Only use when you genuinely need a synthetic mouse click (e.g. testing mouse hover/right-click flyouts where InvokePattern is unavailable)
|
||||
- ✅ Subject to your process having interactive desktop access
|
||||
|
||||
**Rule of thumb**: try `invoke` first; only fall back to `click` if the target lacks InvokePattern AND you have a non-elevated test runner.
|
||||
|
||||
### CRITICAL — DataTemplate AutomationId vs ListItem InvokePattern
|
||||
|
||||
When XAML binds `AutomationProperties.AutomationId="{x:Bind <DataProperty>}"` inside a `ListView.ItemTemplate`'s `<DataTemplate>`, the AutomationId lives on the **inner Grid (Group)** the template produces — NOT on the outer ListItem the ListView wraps around it. The outer ListItem is what carries `InvokePattern`.
|
||||
|
||||
Concrete example (CmdPal PR #48033 binds Command.Id this way):
|
||||
|
||||
```powershell
|
||||
# This FAILS with "does not support any invoke pattern":
|
||||
winapp ui invoke 'com.microsoft.cmdpal.calculator' -w $hwnd
|
||||
# Element grp-commicrosoftcmd-XXXX (Group) does not support any invoke pattern.
|
||||
# No invokable ancestor was found.
|
||||
|
||||
# This WORKS — find by Name (matches all 3 siblings), pick the ListItem child:
|
||||
$r = winapp ui search 'Calculator' -w $hwnd --json | ConvertFrom-Json
|
||||
$li = $r.matches | Where-Object type -eq 'ListItem' | Select-Object -First 1
|
||||
winapp ui invoke $li.selector -w $hwnd # selector like 'itm-calculator-7e3f'
|
||||
```
|
||||
|
||||
If you encounter "does not support any invoke pattern" while trying to use a data-bound AutomationId, this is almost always the cause. The fix is to search by Name and invoke the sibling ListItem.
|
||||
|
||||
### CRITICAL — Keystroke input that bypasses UIPI (PostMessage)
|
||||
|
||||
`winapp ui` has no `send-keys` verb. For keystroke input into elevated/AppX targets where SendInput fails, use **inline Win32 `PostMessage WM_KEYDOWN/WM_KEYUP`** which goes through the target's message queue without UIPI checks:
|
||||
|
||||
```powershell
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class K {
|
||||
[DllImport("user32.dll", CharSet=CharSet.Auto)]
|
||||
public static extern bool PostMessage(IntPtr h, uint msg, IntPtr wp, IntPtr lp);
|
||||
public const uint WM_KEYDOWN = 0x0100;
|
||||
public const uint WM_KEYUP = 0x0101;
|
||||
}
|
||||
"@
|
||||
|
||||
function Send-KeyToHwnd {
|
||||
param([IntPtr]$Hwnd, [byte]$Vk)
|
||||
[void][K]::PostMessage($Hwnd, [K]::WM_KEYDOWN, [IntPtr]$Vk, [IntPtr]0)
|
||||
Start-Sleep -Milliseconds 30
|
||||
[void][K]::PostMessage($Hwnd, [K]::WM_KEYUP, [IntPtr]$Vk, [IntPtr]0)
|
||||
}
|
||||
|
||||
# Common VK codes:
|
||||
# 0x08 Backspace 0x09 Tab 0x0D Enter 0x1B Escape
|
||||
# 0x25 Left 0x26 Up 0x27 Right 0x28 Down
|
||||
Send-KeyToHwnd -Hwnd $h -Vk 0x28 # Down arrow
|
||||
Send-KeyToHwnd -Hwnd $h -Vk 0x0D # Enter
|
||||
```
|
||||
|
||||
**Caveats**:
|
||||
- WinUI3 apps' raw-input hooks may NOT process some keys via WM_KEYDOWN — `Esc` in particular often goes ignored (use BackButton invoke instead). Arrow keys + Enter typically work for ListView navigation.
|
||||
- PostMessage returns immediately; allow 50-200 ms before reading state.
|
||||
- Repeat `Send-KeyToHwnd` calls work for multi-step navigation (Down × 5 to scroll, then Enter).
|
||||
|
||||
### CRITICAL — Global hotkeys / PowerToys activation chords (SendInput, verified working)
|
||||
|
||||
`PostMessage` above targets a specific window's queue. To fire a **global hotkey** (e.g. a PowerToys activation chord like `Win+Shift+C`) you must inject into the **system input stream** with `SendInput` so the low-level keyboard hook (`WH_KEYBOARD_LL`) sees it. This **works for Win+ chords** — the common belief that "Win+ chords can't be injected" is false; it's almost always a **marshaling bug** (`SendInput` returns `0`, `GetLastError()==87`) from building the `INPUT[]` array in PowerShell. Build the array in C#:
|
||||
|
||||
```powershell
|
||||
Add-Type @"
|
||||
using System; using System.Runtime.InteropServices; using System.Collections.Generic;
|
||||
public static class Inj {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct INPUT { public uint type; public KEYBDINPUT ki; public int p1; public int p2; } // p1/p2 pad the union -> cb=40 on x64
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
|
||||
[DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint n, INPUT[] p, int cb);
|
||||
const uint KEYUP = 0x0002;
|
||||
static INPUT K(ushort vk, bool up){ INPUT i=new INPUT(); i.type=1; i.ki.wVk=vk; i.ki.dwFlags=up?KEYUP:0; return i; }
|
||||
public static uint Chord(ushort[] mods, ushort key){ // mods down -> key tap -> mods up (reverse)
|
||||
var l=new List<INPUT>();
|
||||
foreach(var m in mods) l.Add(K(m,false));
|
||||
l.Add(K(key,false)); l.Add(K(key,true));
|
||||
for(int i=mods.Length-1;i>=0;i--) l.Add(K(mods[i],true));
|
||||
var a=l.ToArray(); return SendInput((uint)a.Length,a,Marshal.SizeOf(typeof(INPUT)));
|
||||
}
|
||||
}
|
||||
"@
|
||||
# LWIN=0x5B CTRL=0x11 SHIFT=0x10 ALT=0x12 ; main key VK from the module's settings.json "code"
|
||||
$sent = [Inj]::Chord([uint16[]]@(0x5B,0x10), [uint16]0x43) # Win+Shift+C (Color Picker)
|
||||
if ($sent -eq 0) { throw "SendInput failed err=$([Runtime.InteropServices.Marshal]::GetLastWin32Error())" }
|
||||
```
|
||||
|
||||
**Caveats**:
|
||||
- The injector must run at the **same or higher integrity level** as the hook owner (PowerToys runner). Default per-user installs run the runner at Medium IL, so a normal shell works; if the runner is elevated, run the injector elevated too (otherwise UIPI silently drops the injection).
|
||||
- Must run in the interactive desktop session.
|
||||
- OS-reserved chords (Win+L, Win+Tab) are consumed by Windows before any hook and cannot be injected this way.
|
||||
- Verify the result via the runner trace log line `… hotkey is invoked from Centralized keyboard hook` (`%LOCALAPPDATA%\Microsoft\PowerToys\RunnerLogs\runner-log_<date>.log`) and/or the module's observable side-effect (overlay window, spawned editor process).
|
||||
|
||||
### CRITICAL — Verify foreground BEFORE every SendInput targeting a specific window
|
||||
|
||||
`SendInput` injects into the **session-wide** input stream — it goes to whatever IS foreground at the moment. If your target window has lost foreground (very common with AppX windows), the keys silently land in another window (often your own terminal) with no error returned.
|
||||
|
||||
Always check the foreground state immediately before calling `SendInput`. For winapp ui's output, the literal substring `foreground` appears in the line for the foreground window:
|
||||
|
||||
```powershell
|
||||
function Test-AppForeground {
|
||||
param([Parameter(Mandatory)][string]$AppId)
|
||||
$r = winapp ui list-windows -a $AppId 2>$null | Out-String
|
||||
return ($r -match 'foreground')
|
||||
}
|
||||
|
||||
# Force foreground (works ONCE per session reliably; subsequent attempts may be blocked by
|
||||
# Windows foreground-lock):
|
||||
function Force-AppForeground {
|
||||
param([Parameter(Mandatory)][IntPtr]$Hwnd, [int]$ProcessId)
|
||||
Add-Type -TypeDefinition @'
|
||||
using System; using System.Runtime.InteropServices;
|
||||
public static class Fg {
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
|
||||
[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int pid);
|
||||
}
|
||||
'@ -EA SilentlyContinue
|
||||
[Fg]::AllowSetForegroundWindow($ProcessId) | Out-Null
|
||||
[Fg]::ShowWindow($Hwnd, 9) | Out-Null # SW_RESTORE
|
||||
$fg = [Fg]::GetForegroundWindow(); $fgPid = 0
|
||||
$fgThread = [Fg]::GetWindowThreadProcessId($fg, [ref]$fgPid)
|
||||
$curThread = [Fg]::GetCurrentThreadId()
|
||||
if ($fgThread -ne 0 -and $fgThread -ne $curThread) { [Fg]::AttachThreadInput($curThread, $fgThread, $true) | Out-Null }
|
||||
[Fg]::BringWindowToTop($Hwnd) | Out-Null
|
||||
[Fg]::SetForegroundWindow($Hwnd) | Out-Null
|
||||
if ($fgThread -ne 0 -and $fgThread -ne $curThread) { [Fg]::AttachThreadInput($curThread, $fgThread, $false) | Out-Null }
|
||||
Start-Sleep -Milliseconds 400
|
||||
}
|
||||
|
||||
# Guard pattern: abort instead of silently sending keys to wrong window
|
||||
if (-not (Test-AppForeground -AppId 'Microsoft.CmdPal.UI')) {
|
||||
Force-AppForeground -Hwnd $h -ProcessId $pid
|
||||
if (-not (Test-AppForeground -AppId 'Microsoft.CmdPal.UI')) {
|
||||
throw 'Cannot force CmdPal foreground; aborting SendInput batch'
|
||||
}
|
||||
}
|
||||
# ... now safe to SendInput ...
|
||||
```
|
||||
|
||||
**Tip**: when foreground cannot be reliably maintained, prefer `winapp ui set-value` (UIA-IPC, no foreground required) or `winapp ui invoke` (UIA InvokePattern, no foreground required) instead of SendInput.
|
||||
|
||||
### CRITICAL — `set-value` bypasses TextChanged for some apps (CmdPal alias detection)
|
||||
|
||||
`winapp ui set-value` writes the value through UIA's ValuePattern, which fires a programmatic value-change event. **It does NOT raise the `TextBox.TextChanged` event** the way real keystrokes do. For apps whose logic listens to `TextChanged` rather than to property changes — most notably CmdPal's alias detection (typing `=`, `<`, `>`, `:`, `$`, `??`, `)` in MainSearchBox triggers navigation to a provider sub-page) — `set-value` will set the text but the alias will NOT activate.
|
||||
|
||||
Workarounds:
|
||||
- For plain queries: `winapp ui set-value` works fine (CmdPal still re-runs all providers on value change).
|
||||
- For alias-triggered navigation: use **real keystrokes** via Force-AppForeground + SendInput, typing one character at a time with ~60-100ms delay so the alias detector sees the TextChanged sequence.
|
||||
- Alternative: invoke the provider tile directly by its stable AutomationId (e.g. `winapp ui invoke 'com.microsoft.cmdpal.calculator' -w $hwnd`) when you only need the destination page, not the alias path.
|
||||
|
||||
### CRITICAL — Stunted UIA tree recovery
|
||||
|
||||
After ~30+ rapid `set-value` calls or after AppX has been interactive too long, an AppX window's UIA tree can degrade to a "stunted" state where `winapp ui inspect -w $h --depth 6` returns only ~5 elements (TitleBar / Close / Min / Max / RootPane) — even though the app looks fine visually.
|
||||
|
||||
Probe + recover pattern:
|
||||
|
||||
```powershell
|
||||
# Probe: any healthy ListView-based AppX has >50 UIA nodes at depth 6
|
||||
$probe = winapp ui inspect -w $h --depth 6 --json | ConvertFrom-Json
|
||||
$nodes = 0
|
||||
$stack = [System.Collections.Stack]::new()
|
||||
if ($probe.windows[0].elements) { foreach ($e in $probe.windows[0].elements) { $stack.Push($e) } }
|
||||
while ($stack.Count -gt 0) {
|
||||
$n = $stack.Pop(); $nodes++
|
||||
if ($n.PSObject.Properties['children']) { foreach ($c in $n.children) { $stack.Push($c) } }
|
||||
}
|
||||
|
||||
if ($nodes -lt 6) {
|
||||
Write-Warning "UIA tree stunted ($nodes nodes); restarting AppX"
|
||||
Get-Process Microsoft.CmdPal.UI -EA SilentlyContinue | ForEach-Object {
|
||||
Stop-Process -Id $_.Id -Force
|
||||
Wait-Process -Id $_.Id -Timeout 5 -EA SilentlyContinue
|
||||
}
|
||||
Start-Process 'shell:AppsFolder\Microsoft.CommandPalette_8wekyb3d8bbwe!App'
|
||||
Start-Sleep 5
|
||||
# Re-resolve HWND with list-windows
|
||||
}
|
||||
```
|
||||
|
||||
### Settings.json mutation safety contract
|
||||
|
||||
When the only realistic way to reach a needed test state is editing the app's persistent settings (e.g. multi-select that the UI's `SelectionItemPattern.Select` clobbers), wrap mutations with **byte-identical backup + restore-on-exit**:
|
||||
|
||||
```powershell
|
||||
$settings = "$env:LOCALAPPDATA\Packages\Microsoft.CommandPalette_8wekyb3d8bbwe\LocalState\settings.json"
|
||||
$backup = "$env:TEMP\settings-backup-$(Get-Random).json"
|
||||
$origBytes = [System.IO.File]::ReadAllBytes($settings)
|
||||
[System.IO.File]::WriteAllBytes($backup, $origBytes)
|
||||
try {
|
||||
# 1. Stop the AppX so we can write the file (apps usually hold it open)
|
||||
Get-Process Microsoft.CmdPal.UI -EA SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep 1
|
||||
# 2. Mutate
|
||||
$j = $origBytes | ForEach-Object { [char]$_ } | Join-String | ConvertFrom-Json
|
||||
$j.SomeKey = 'TestValue'
|
||||
[System.IO.File]::WriteAllBytes($settings, [System.Text.Encoding]::UTF8.GetBytes(($j | ConvertTo-Json -Depth 10)))
|
||||
# 3. Restart AppX so it re-reads the mutated settings
|
||||
Start-Process 'shell:AppsFolder\Microsoft.CommandPalette_8wekyb3d8bbwe!App'
|
||||
Start-Sleep 5
|
||||
# 4. ... run your test ...
|
||||
} finally {
|
||||
# ALWAYS restore — verify byte-identical via length + SHA256
|
||||
Get-Process Microsoft.CmdPal.UI -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue
|
||||
Start-Sleep 1
|
||||
[System.IO.File]::WriteAllBytes($settings, $origBytes)
|
||||
$check = [System.IO.File]::ReadAllBytes($settings)
|
||||
if ($check.Length -ne $origBytes.Length) { Write-Error "Restore length mismatch!" }
|
||||
Start-Process 'shell:AppsFolder\Microsoft.CommandPalette_8wekyb3d8bbwe!App'
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: this should be used ONLY when the UI route is unreachable. Any setting flippable through the AppX Settings UI should be flipped that way instead (it's the documented user flow and tests real binding code).
|
||||
|
||||
76
.github/skills/powertoys-module-verification/scripts/pt-admin-probe.ps1
vendored
Normal file
76
.github/skills/powertoys-module-verification/scripts/pt-admin-probe.ps1
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# scripts/pt-admin-probe.ps1
|
||||
# Verify the current session is elevated AND that PT runner inherits the admin token.
|
||||
|
||||
if (-not ('PtTok' -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class PtTok {
|
||||
[DllImport("advapi32.dll", SetLastError=true)]
|
||||
public static extern bool OpenProcessToken(IntPtr h, uint da, out IntPtr t);
|
||||
[DllImport("advapi32.dll", SetLastError=true)]
|
||||
public static extern bool GetTokenInformation(IntPtr t, uint c, IntPtr ti, uint l, out uint rl);
|
||||
[DllImport("kernel32.dll")] public static extern IntPtr GetCurrentProcess();
|
||||
[DllImport("kernel32.dll")] public static extern IntPtr OpenProcess(uint da, bool inh, int pid);
|
||||
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
function Test-PtAdmin {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check whether the current session is elevated by reading the process token's TokenElevation
|
||||
information class (20). Returns $true if elevated.
|
||||
#>
|
||||
[CmdletBinding()] param()
|
||||
$t = [IntPtr]::Zero
|
||||
[PtTok]::OpenProcessToken([PtTok]::GetCurrentProcess(), 8, [ref]$t) | Out-Null
|
||||
$ti = [Runtime.InteropServices.Marshal]::AllocHGlobal(4)
|
||||
$rl = 0
|
||||
try {
|
||||
[PtTok]::GetTokenInformation($t, 20, $ti, 4, [ref]$rl) | Out-Null
|
||||
return ([Runtime.InteropServices.Marshal]::ReadInt32($ti) -eq 1)
|
||||
} finally {
|
||||
[Runtime.InteropServices.Marshal]::FreeHGlobal($ti)
|
||||
[PtTok]::CloseHandle($t) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ProcessElevated {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check whether a specific PID is elevated (TokenElevation = 1).
|
||||
#>
|
||||
[CmdletBinding()] param([Parameter(Mandatory)][int]$ProcessId)
|
||||
$proc = [PtTok]::OpenProcess(0x1000, $false, $ProcessId) # PROCESS_QUERY_LIMITED_INFORMATION
|
||||
if ($proc -eq [IntPtr]::Zero) { return $null }
|
||||
try {
|
||||
$t = [IntPtr]::Zero
|
||||
if (-not [PtTok]::OpenProcessToken($proc, 8, [ref]$t)) { return $null }
|
||||
try {
|
||||
$ti = [Runtime.InteropServices.Marshal]::AllocHGlobal(4)
|
||||
$rl = 0
|
||||
try {
|
||||
[PtTok]::GetTokenInformation($t, 20, $ti, 4, [ref]$rl) | Out-Null
|
||||
return ([Runtime.InteropServices.Marshal]::ReadInt32($ti) -eq 1)
|
||||
} finally { [Runtime.InteropServices.Marshal]::FreeHGlobal($ti) }
|
||||
} finally { [PtTok]::CloseHandle($t) | Out-Null }
|
||||
} finally { [PtTok]::CloseHandle($proc) | Out-Null }
|
||||
}
|
||||
|
||||
function Test-PtRunnerAdmin {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check whether the PT runner (PowerToys.exe) is currently running elevated.
|
||||
.OUTPUTS
|
||||
PSCustomObject with .Found (bool), .Pid (int), .Elevated (bool|$null)
|
||||
#>
|
||||
$pt = Get-Process PowerToys -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $pt) { return [pscustomobject]@{ Found=$false; Pid=$null; Elevated=$null } }
|
||||
[pscustomobject]@{
|
||||
Found = $true
|
||||
Pid = $pt.Id
|
||||
Elevated = (Test-ProcessElevated -ProcessId $pt.Id)
|
||||
}
|
||||
}
|
||||
61
.github/skills/powertoys-module-verification/scripts/pt-clipboard-diff.ps1
vendored
Normal file
61
.github/skills/powertoys-module-verification/scripts/pt-clipboard-diff.ps1
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
# scripts/pt-clipboard-diff.ps1
|
||||
# Multi-format clipboard inspection. Used to assert that AdvancedPaste plain-paste actually strips
|
||||
# rich formats while preserving UnicodeText (and similar before/after assertions).
|
||||
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
function Get-PtClipboardFormats {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Return the list of format names currently on the clipboard (e.g. UnicodeText, HTML Format,
|
||||
Rich Text Format, FileDrop, DeviceIndependentBitmap, etc.).
|
||||
#>
|
||||
$obj = [System.Windows.Forms.Clipboard]::GetDataObject()
|
||||
if (-not $obj) { return @() }
|
||||
return $obj.GetFormats()
|
||||
}
|
||||
|
||||
function Get-PtClipboardText {
|
||||
[System.Windows.Forms.Clipboard]::GetText()
|
||||
}
|
||||
|
||||
function Compare-PtClipboardFormatDiff {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Diff helper. Given a 'before' formats list (from Get-PtClipboardFormats), return:
|
||||
- Added: formats present in current clipboard but not in before
|
||||
- Removed: formats present in before but not in current
|
||||
- Common: formats present in both
|
||||
.EXAMPLE
|
||||
$before = Get-PtClipboardFormats # e.g. UnicodeText + HTML Format + RTF
|
||||
# ... user/script triggers AP plain-paste ...
|
||||
$diff = Compare-PtClipboardFormatDiff -Before $before
|
||||
# $diff.Removed should contain 'HTML Format' and 'Rich Text Format'
|
||||
# $diff.Common should still contain 'UnicodeText'
|
||||
#>
|
||||
param([Parameter(Mandatory)][string[]]$Before)
|
||||
$current = Get-PtClipboardFormats
|
||||
[pscustomobject]@{
|
||||
Before = $Before
|
||||
Current = $current
|
||||
Added = @($current | Where-Object { $_ -notin $Before })
|
||||
Removed = @($Before | Where-Object { $_ -notin $current })
|
||||
Common = @($current | Where-Object { $_ -in $Before })
|
||||
}
|
||||
}
|
||||
|
||||
function Set-PtClipboardRich {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Put HTML + UnicodeText on the clipboard so plain-paste detection has something to strip.
|
||||
Useful as test fixture before invoking AdvancedPaste.PasteAsPlainText.
|
||||
#>
|
||||
param(
|
||||
[string]$Text = 'Hello world',
|
||||
[string]$Html = '<html><body><b>Hello</b> <i>world</i></body></html>'
|
||||
)
|
||||
$obj = New-Object System.Windows.Forms.DataObject
|
||||
$obj.SetText($Text, [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
||||
$obj.SetText($Html, [System.Windows.Forms.TextDataFormat]::Html)
|
||||
[System.Windows.Forms.Clipboard]::SetDataObject($obj, $true)
|
||||
}
|
||||
99
.github/skills/powertoys-module-verification/scripts/pt-cmdpal-recycle.ps1
vendored
Normal file
99
.github/skills/powertoys-module-verification/scripts/pt-cmdpal-recycle.ps1
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
# scripts/pt-cmdpal-recycle.ps1
|
||||
# Recover CmdPal AppX from "stuck" states (TextChanged-broken, sub-page hang, foreground-lock).
|
||||
# The helper Microsoft.CmdPal.Ext.PowerToys is kept alive so the CmdPal.Show event listener wiring
|
||||
# is not lost on recycle.
|
||||
|
||||
function Reset-CmdPalAppX {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Kill the Microsoft.CmdPal.UI process and relaunch the AppX. Returns the new HWND or 0 on failure.
|
||||
.NOTES
|
||||
Symptoms requiring this:
|
||||
- set-value MainSearchBox echoes the text but ZERO ListItems appear within 1.5s
|
||||
- winapp ui invoke <button> hangs subsequent inspect calls
|
||||
- Force-PtForeground returns false repeatedly
|
||||
#>
|
||||
$cp = Get-Process Microsoft.CmdPal.UI -ErrorAction SilentlyContinue
|
||||
if ($cp) {
|
||||
Stop-Process -Id $cp.Id -Force
|
||||
$deadline = (Get-Date).AddSeconds(5)
|
||||
while ((Get-Process -Id $cp.Id -ErrorAction SilentlyContinue) -and (Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
}
|
||||
Start-Process 'shell:AppsFolder\Microsoft.CommandPalette_8wekyb3d8bbwe!App'
|
||||
$deadline = (Get-Date).AddSeconds(10)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 300
|
||||
$r = winapp ui list-windows -a Microsoft.CmdPal.UI 2>$null | Out-String
|
||||
if ($r -match 'HWND (\d+):') { return [IntPtr][int64]$matches[1] }
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
return [IntPtr]::Zero
|
||||
}
|
||||
|
||||
function Reset-CmdPalToHome {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Navigate CmdPal back to the home page from any sub-page by invoking BackButton via UIA.
|
||||
CmdPal's Esc handler is unreachable via SendInput from elevated sessions (UIPI), and Esc-via-
|
||||
PostMessage is filtered by the WinUI 3 raw-input hook. BackButton invoke via UIA InvokePattern
|
||||
works regardless.
|
||||
#>
|
||||
$homePlaceholder = 'Search for apps, files and commands'
|
||||
for ($i = 0; $i -lt 6; $i++) {
|
||||
$cur = winapp ui get-value 'MainSearchBox' -a Microsoft.CmdPal.UI 2>$null
|
||||
if ($cur -and ($cur -match [regex]::Escape($homePlaceholder))) { break }
|
||||
winapp ui invoke 'BackButton' -a Microsoft.CmdPal.UI 2>$null | Out-Null
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
# Re-signal Show in case BackButton dismissed the window
|
||||
if (Get-Command Invoke-PtSharedEvent -ErrorAction SilentlyContinue) {
|
||||
try { Invoke-PtSharedEvent -Name 'CmdPal.Show' | Out-Null } catch {}
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
function Test-CmdPalDegraded {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Probe the AppX with a known-good query ('notepad') and verify >=1 ListItem appears within
|
||||
1500ms. Returns $true if degraded (TextChanged-broken).
|
||||
#>
|
||||
Reset-CmdPalToHome
|
||||
winapp ui set-value 'MainSearchBox' 'notepad' -a Microsoft.CmdPal.UI 2>$null | Out-Null
|
||||
$deadline = (Get-Date).AddMilliseconds(1500)
|
||||
do {
|
||||
$insLines = (winapp ui inspect -a Microsoft.CmdPal.UI --depth 7 -i 2>$null) -split "`n"
|
||||
$items = $insLines | Where-Object { $_ -match 'itm-' -and $_ -match 'ListItem' }
|
||||
if ($items.Count -gt 0) {
|
||||
winapp ui set-value 'MainSearchBox' '' -a Microsoft.CmdPal.UI 2>$null | Out-Null
|
||||
return $false
|
||||
}
|
||||
Start-Sleep -Milliseconds 150
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-CmdPalQuery {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Type a query into MainSearchBox after returning to home. Auto-recovers if AppX is degraded.
|
||||
Returns the result items as an array of strings (text lines starting with itm-).
|
||||
.EXAMPLE
|
||||
$items = Invoke-CmdPalQuery -Query 'notepad'
|
||||
if ($items | Where-Object { $_ -match 'Notepad' }) { 'PASS' } else { 'FAIL' }
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Query, [int]$WaitMs = 800)
|
||||
Reset-CmdPalToHome
|
||||
winapp ui set-value 'MainSearchBox' $Query -a Microsoft.CmdPal.UI 2>$null | Out-Null
|
||||
Start-Sleep -Milliseconds $WaitMs
|
||||
$out = winapp ui inspect -a Microsoft.CmdPal.UI --depth 7 -i 2>$null | Out-String
|
||||
$items = ($out -split "`r?`n" | Where-Object { $_ -match 'itm-' -and $_ -match 'ListItem' })
|
||||
if ($items.Count -eq 0) {
|
||||
if (Test-CmdPalDegraded) {
|
||||
Reset-CmdPalAppX | Out-Null
|
||||
return (Invoke-CmdPalQuery -Query $Query -WaitMs $WaitMs)
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
136
.github/skills/powertoys-module-verification/scripts/pt-explorer-com.ps1
vendored
Normal file
136
.github/skills/powertoys-module-verification/scripts/pt-explorer-com.ps1
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
# scripts/pt-explorer-com.ps1
|
||||
# Drive Explorer windows via Shell.Application COM to set up file selections, then trigger
|
||||
# PT modules that read IShellItemArray from the foreground Explorer window (Peek, Image Resizer,
|
||||
# PowerRename, File Locksmith, Workspaces).
|
||||
#
|
||||
# This bypasses needing a real mouse / interactive selection — Shell COM does the selection
|
||||
# programmatically, then the PT hotkey (e.g. Ctrl+Space for Peek) fires the centralized hook
|
||||
# which reads Explorer's selection at the moment of activation.
|
||||
#
|
||||
# Requires an interactive desktop session. If GetForegroundWindow() returns 0 or no Explorer
|
||||
# windows are open, the functions return $null/$false instead of throwing — callers should
|
||||
# treat that as a BLK-ENV signal (an environment block, not a product FAIL).
|
||||
|
||||
function Get-PtExplorerWindows {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Return all open Explorer windows as Shell COM objects (with .LocationName, .Document.Folder, etc.).
|
||||
Returns @() if no Explorer windows are open.
|
||||
#>
|
||||
try {
|
||||
$shell = New-Object -ComObject Shell.Application
|
||||
return @($shell.Windows() | Where-Object { $_.Name -eq 'File Explorer' -or $_.FullName -match 'explorer\.exe$' })
|
||||
} catch { return @() }
|
||||
}
|
||||
|
||||
function Open-PtExplorerAtPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Open a fresh Explorer window at the given path. Returns the Shell COM window object.
|
||||
Useful when no Explorer is open yet.
|
||||
#>
|
||||
[CmdletBinding()] param([Parameter(Mandatory)][string]$Path)
|
||||
if (-not (Test-Path $Path)) { throw "Path not found: $Path" }
|
||||
Start-Process explorer.exe -ArgumentList $Path
|
||||
Start-Sleep -Milliseconds 1500
|
||||
$wins = Get-PtExplorerWindows
|
||||
# Note: the -replace must be wrapped in its own parens, otherwise the ',' in -replace '\\','/'
|
||||
# is parsed as a second argument to [regex]::Escape() (overload error: "argument count: 2").
|
||||
$needle = [regex]::Escape(((Resolve-Path $Path).Path -replace '\\','/'))
|
||||
return ($wins | Where-Object { $_.LocationURL -match $needle } | Select-Object -First 1)
|
||||
}
|
||||
|
||||
function Select-PtExplorerFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Select 1+ files in an open Explorer window via Shell COM. The window comes to foreground.
|
||||
.DESCRIPTION
|
||||
Uses Shell.Application's SelectItem(item, flags) API. Flags:
|
||||
0x01 = SVSI_SELECT
|
||||
0x04 = SVSI_DESELECTOTHERS (apply to the first item only when selecting multiple)
|
||||
0x08 = SVSI_ENSUREVISIBLE
|
||||
0x20 = SVSI_FOCUSED
|
||||
Returns $true on success, $false if any file wasn't found in the folder.
|
||||
.EXAMPLE
|
||||
$win = Get-PtExplorerWindows | Select-Object -First 1
|
||||
Select-PtExplorerFiles -ExplorerWindow $win -FileNames 'test-markdown.md','test-html.html','test-source.cs'
|
||||
Send-PtChord -Mods 0x11 -Key 0x20 # Ctrl+Space → Peek opens on 3 selected files
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]$ExplorerWindow,
|
||||
[Parameter(Mandatory)][string[]]$FileNames
|
||||
)
|
||||
if (-not $ExplorerWindow.Document) { return $false }
|
||||
$folder = $ExplorerWindow.Document.Folder
|
||||
$first = $true
|
||||
foreach ($name in $FileNames) {
|
||||
$item = $folder.ParseName($name)
|
||||
if (-not $item) { Write-Warning "File not found in folder: $name"; return $false }
|
||||
# First item: SELECT + DESELECTOTHERS + ENSUREVISIBLE + FOCUSED = 0x2D
|
||||
# Subsequent items: SELECT + ENSUREVISIBLE = 0x09
|
||||
$flags = if ($first) { 0x2D } else { 0x09 }
|
||||
$ExplorerWindow.Document.SelectItem($item, $flags)
|
||||
$first = $false
|
||||
}
|
||||
Start-Sleep -Milliseconds 300
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-PtPeekWithExplorerSelection {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Set up an Explorer multi-file selection and trigger Peek via Ctrl+Space.
|
||||
Returns the new Peek window HWND, or $null on failure.
|
||||
.EXAMPLE
|
||||
$h = Invoke-PtPeekWithExplorerSelection -FolderPath D:\fixtures -FileNames 'a.png','b.md','c.cs'
|
||||
winapp ui invoke PinButton -w $h
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$FolderPath,
|
||||
[Parameter(Mandatory)][string[]]$FileNames
|
||||
)
|
||||
$win = Get-PtExplorerWindows | Where-Object { $_.LocationURL -match [regex]::Escape(($FolderPath -replace '\\','/')) } | Select-Object -First 1
|
||||
if (-not $win) { $win = Open-PtExplorerAtPath -Path $FolderPath }
|
||||
if (-not $win) { return $null }
|
||||
if (-not (Select-PtExplorerFiles -ExplorerWindow $win -FileNames $FileNames)) { return $null }
|
||||
|
||||
# Capture pre-state Peek HWND list to detect the new window
|
||||
$beforeHwnds = @(Get-Process PowerToys.Peek.UI -EA SilentlyContinue | ForEach-Object MainWindowHandle)
|
||||
|
||||
# Fire Ctrl+Space (Peek default). Requires pt-sendinput-chord.ps1 to be dot-sourced first.
|
||||
if (-not (Get-Command Send-PtChord -EA SilentlyContinue)) {
|
||||
throw "Send-PtChord not loaded. Dot-source scripts/pt-sendinput-chord.ps1 first."
|
||||
}
|
||||
Send-PtChord -Mods 0x11 -Key 0x20 | Out-Null # Ctrl+Space
|
||||
Start-Sleep -Milliseconds 1200
|
||||
|
||||
# Find the new Peek window HWND
|
||||
$afterHwnds = @(Get-Process PowerToys.Peek.UI -EA SilentlyContinue | ForEach-Object MainWindowHandle)
|
||||
$new = $afterHwnds | Where-Object { $_ -ne 0 -and $_ -notin $beforeHwnds } | Select-Object -First 1
|
||||
if (-not $new) { $new = $afterHwnds | Where-Object { $_ -ne 0 } | Select-Object -First 1 }
|
||||
return $new
|
||||
}
|
||||
|
||||
function Test-PtInteractiveDesktop {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Probe whether the current session is interactive (foreground + Shell COM both working).
|
||||
Returns a PSCustomObject with .ForegroundOk and .ShellComOk.
|
||||
.EXAMPLE
|
||||
$env = Test-PtInteractiveDesktop
|
||||
if (-not $env.ForegroundOk -or -not $env.ShellComOk) {
|
||||
Write-Warning "Non-interactive session — Explorer-driven techniques will fail."
|
||||
}
|
||||
#>
|
||||
Add-Type 'using System; using System.Runtime.InteropServices; public class FG3 { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); }' -EA SilentlyContinue
|
||||
$hasFg = $false
|
||||
for ($i = 0; $i -lt 5; $i++) {
|
||||
if ([FG3]::GetForegroundWindow() -ne [IntPtr]::Zero) { $hasFg = $true; break }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
$shellOk = $false
|
||||
try { @((New-Object -ComObject Shell.Application).Windows()).Count | Out-Null; $shellOk = $true } catch {}
|
||||
[pscustomobject]@{ ForegroundOk = $hasFg; ShellComOk = $shellOk }
|
||||
}
|
||||
96
.github/skills/powertoys-module-verification/scripts/pt-explorer-contextmenu.ps1
vendored
Normal file
96
.github/skills/powertoys-module-verification/scripts/pt-explorer-contextmenu.ps1
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
# pt-explorer-contextmenu.ps1 — drive any Explorer (Win11) context-menu PowerToys module
|
||||
# end-to-end the way a real user does: open Explorer, select file(s), synthetic right-click
|
||||
# to OPEN the menu, then UIA-invoke the module's menu item by NAME (robust — no coordinate
|
||||
# click). Used by File Locksmith, Image Resizer, PowerRename, New+, etc.
|
||||
#
|
||||
# See explorer-context-menu-flow.md for the full write-up, stability notes, and per-module captions.
|
||||
#
|
||||
# Requires an UNLOCKED interactive desktop (synthetic right-click needs foreground). Check first:
|
||||
# if ([PtFg]::GetForegroundWindow() -eq [IntPtr]::Zero) -> desktop locked -> BLK-ENV.
|
||||
|
||||
Add-Type -TypeDefinition @'
|
||||
using System; using System.Runtime.InteropServices;
|
||||
public static class PtCtx {
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, IntPtr e);
|
||||
public const uint RIGHTDOWN=0x0008, RIGHTUP=0x0010, LEFTDOWN=0x0002, LEFTUP=0x0004;
|
||||
public static void ForceForeground(IntPtr h) {
|
||||
IntPtr fg = GetForegroundWindow(); uint fp;
|
||||
uint ft = GetWindowThreadProcessId(fg, out fp); uint ct = GetCurrentThreadId();
|
||||
ShowWindow(h, 9);
|
||||
if (ft != 0 && ft != ct) AttachThreadInput(ct, ft, true);
|
||||
BringWindowToTop(h); SetForegroundWindow(h);
|
||||
if (ft != 0 && ft != ct) AttachThreadInput(ct, ft, false);
|
||||
}
|
||||
public static void RightClick(int x, int y) {
|
||||
SetCursorPos(x, y); System.Threading.Thread.Sleep(250);
|
||||
mouse_event(RIGHTDOWN,0,0,0,IntPtr.Zero); System.Threading.Thread.Sleep(70); mouse_event(RIGHTUP,0,0,0,IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
'@ -ErrorAction SilentlyContinue
|
||||
|
||||
function Test-PtDesktopInteractive {
|
||||
# Polls up to $TimeoutSec for a foreground window. A momentary 0 is common for a few seconds
|
||||
# right after Restart-PtRunner / Explorer restart — without the poll that blip is misclassified
|
||||
# as a locked desktop (false BLK-ENV). A genuinely locked/non-interactive desktop stays 0 for
|
||||
# the whole window and still returns $false.
|
||||
param([int]$TimeoutSec = 5)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
do {
|
||||
if ([PtCtx]::GetForegroundWindow() -ne [IntPtr]::Zero) { return $true }
|
||||
Start-Sleep -Milliseconds 250
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
return $false
|
||||
}
|
||||
|
||||
# Opens the Win11 context menu for a file in an already-open Explorer window and returns the
|
||||
# menu popup HWND. $ExplorerHwnd = the CabinetWClass window; $FileName = item to right-click.
|
||||
function Open-PtExplorerContextMenu {
|
||||
param([Parameter(Mandatory)][int]$ExplorerHwnd, [Parameter(Mandatory)][string]$FileName, [int]$MaxTries = 3)
|
||||
if (-not (Test-PtDesktopInteractive)) { throw 'BLK-ENV: desktop is locked / no foreground (GetForegroundWindow()=0). Unlock and retry.' }
|
||||
for ($try = 1; $try -le $MaxTries; $try++) {
|
||||
[PtCtx]::ForceForeground([IntPtr]$ExplorerHwnd); Start-Sleep -Milliseconds 500
|
||||
$item = (winapp ui search $FileName -w $ExplorerHwnd --json 2>$null | ConvertFrom-Json).matches |
|
||||
Where-Object { $_.type -eq 'ListItem' } | Select-Object -First 1
|
||||
if (-not $item) { throw "File item '$FileName' not found in Explorer window $ExplorerHwnd" }
|
||||
# Right-click near the row's LEFT edge (on the filename), not the geometric center:
|
||||
# in Details view the ListItem rect spans ~full row width (measured 71% of window), so
|
||||
# x+width/2 lands far right over other columns / empty canvas → background menu or missed
|
||||
# click. x + min(80, width/2) is on the filename in Details AND on the tile in Icons view.
|
||||
[PtCtx]::RightClick([int]($item.x + [Math]::Min(80, $item.width/2)), [int]($item.y + $item.height/2))
|
||||
Start-Sleep -Seconds 2
|
||||
# The Win11 menu is its own top-level popup window:
|
||||
$menu = winapp ui list-windows --json 2>$null | ConvertFrom-Json |
|
||||
Where-Object { $_.className -match 'PopupWindowSiteBridge' } | Sort-Object height -Descending | Select-Object -First 1
|
||||
if ($menu) { return $menu.hwnd }
|
||||
Start-Sleep -Milliseconds 500 # retry: foreground/menu wasn't ready (common on the first attempt right after Explorer opens)
|
||||
}
|
||||
throw "Context-menu popup window not found after $MaxTries right-click attempts"
|
||||
}
|
||||
|
||||
# Invokes a context-menu item by its visible NAME (robust — UIA InvokePattern, no coord click).
|
||||
# Returns $true if invoked. Match the module caption, e.g.:
|
||||
# File Locksmith -> 'Unlock with File Locksmith' PowerRename -> 'Rename with PowerRename'
|
||||
# Image Resizer -> 'Resize images' (verify by enumerating) New+ -> 'New+'
|
||||
function Invoke-PtContextMenuItem {
|
||||
param([Parameter(Mandatory)][int]$MenuHwnd, [Parameter(Mandatory)][string]$ItemName)
|
||||
$m = (winapp ui search $ItemName -w $MenuHwnd --json 2>$null | ConvertFrom-Json).matches |
|
||||
Where-Object { $_.type -eq 'MenuItem' } | Select-Object -First 1
|
||||
if (-not $m) { return $false } # caller can treat $false as "entry absent" (e.g. module disabled)
|
||||
winapp ui invoke $m.selector -w $MenuHwnd 2>$null | Out-Null
|
||||
return $true
|
||||
}
|
||||
|
||||
# Lists all context-menu item names (for discovering a module's caption or asserting absence).
|
||||
function Get-PtContextMenuItems {
|
||||
param([Parameter(Mandatory)][int]$MenuHwnd)
|
||||
winapp ui inspect -w $MenuHwnd --depth 8 2>$null | Out-String |
|
||||
Select-String 'MenuItem "([^"]+)"' -AllMatches | ForEach-Object { $_.Matches } | ForEach-Object { $_.Groups[1].Value }
|
||||
}
|
||||
100
.github/skills/powertoys-module-verification/scripts/pt-foreground-guard.ps1
vendored
Normal file
100
.github/skills/powertoys-module-verification/scripts/pt-foreground-guard.ps1
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
# scripts/pt-foreground-guard.ps1
|
||||
# Verify and force a window to foreground BEFORE sending SendInput.
|
||||
# Without this guard, SendInput keys silently leak to the caller's terminal when
|
||||
# the target window has lost foreground (common with CmdPal AppX where Windows
|
||||
# foreground-lock blocks SetForegroundWindow after the first attempt).
|
||||
#
|
||||
# Use winapp ui set-value for UIA-friendly inputs (no foreground required).
|
||||
# Use this guard ONLY when you need real keystrokes (e.g. CmdPal alias detection).
|
||||
|
||||
if (-not ('PtFg' -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class PtFg {
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
|
||||
[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int pid);
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
function Test-PtForeground {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check whether the target AppX is currently foreground by parsing winapp ui list-windows output
|
||||
for the literal substring 'foreground'.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$AppId)
|
||||
$r = winapp ui list-windows -a $AppId 2>$null | Out-String
|
||||
return ($r -match 'foreground')
|
||||
}
|
||||
|
||||
function Get-PtHwnd {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Return the first HWND for the given AppX/exe. Returns [IntPtr]::Zero if none.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$AppId)
|
||||
$r = winapp ui list-windows -a $AppId 2>$null | Out-String
|
||||
if ($r -match 'HWND (\d+):') { return [IntPtr][int64]$matches[1] }
|
||||
return [IntPtr]::Zero
|
||||
}
|
||||
|
||||
function Force-PtForeground {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Force the target AppX window to foreground using the AttachThreadInput + AllowSetForegroundWindow
|
||||
trick. Returns $true if window is foreground after this attempt; $false otherwise.
|
||||
.NOTES
|
||||
Windows foreground-lock will block subsequent SetForegroundWindow calls in the same session if
|
||||
a real interactive event hasn't fired recently. If this returns $false repeatedly, the only
|
||||
reliable recovery is to recycle the AppX (kill + relaunch via shell:AppsFolder URI).
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$AppId)
|
||||
$h = Get-PtHwnd -AppId $AppId
|
||||
if ($h -eq [IntPtr]::Zero) { return $false }
|
||||
|
||||
# Permission grant
|
||||
$proc = Get-Process | Where-Object { $_.MainWindowHandle -eq $h } | Select-Object -First 1
|
||||
if ($proc) { [PtFg]::AllowSetForegroundWindow($proc.Id) | Out-Null }
|
||||
|
||||
[PtFg]::ShowWindow($h, 9) | Out-Null # SW_RESTORE
|
||||
Start-Sleep -Milliseconds 150
|
||||
|
||||
# AttachThreadInput trick
|
||||
$fg = [PtFg]::GetForegroundWindow()
|
||||
$fgPid = 0
|
||||
$fgThread = [PtFg]::GetWindowThreadProcessId($fg, [ref]$fgPid)
|
||||
$curThread = [PtFg]::GetCurrentThreadId()
|
||||
if ($fgThread -ne 0 -and $fgThread -ne $curThread) {
|
||||
[PtFg]::AttachThreadInput($curThread, $fgThread, $true) | Out-Null
|
||||
}
|
||||
[PtFg]::BringWindowToTop($h) | Out-Null
|
||||
[PtFg]::SetForegroundWindow($h) | Out-Null
|
||||
[PtFg]::ShowWindow($h, 5) | Out-Null # SW_SHOW
|
||||
if ($fgThread -ne 0 -and $fgThread -ne $curThread) {
|
||||
[PtFg]::AttachThreadInput($curThread, $fgThread, $false) | Out-Null
|
||||
}
|
||||
Start-Sleep -Milliseconds 400
|
||||
return (Test-PtForeground -AppId $AppId)
|
||||
}
|
||||
|
||||
function Assert-PtForegroundOrAbort {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Guard helper. Throws if the target AppX is NOT foreground. Use this immediately before any
|
||||
SendInput call to ensure keys don't leak to the wrong window.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$AppId)
|
||||
if (-not (Test-PtForeground -AppId $AppId)) {
|
||||
if (-not (Force-PtForeground -AppId $AppId)) {
|
||||
throw "ABORT: $AppId is not foreground and cannot be forced foreground. SendInput would leak to wrong window."
|
||||
}
|
||||
}
|
||||
}
|
||||
76
.github/skills/powertoys-module-verification/scripts/pt-nonelevated.ps1
vendored
Normal file
76
.github/skills/powertoys-module-verification/scripts/pt-nonelevated.ps1
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# pt-nonelevated.ps1 — launch a process at MEDIUM integrity (non-elevated) from an
|
||||
# already-elevated agent shell. Needed for tests that assert elevation-dependent
|
||||
# visibility (e.g. File Locksmith L649/L650: a non-elevated module must NOT see
|
||||
# higher-integrity processes; an elevated one must).
|
||||
#
|
||||
# The drive-stack in SKILL.md only covers gaining MORE privilege. De-elevation is the
|
||||
# opposite problem: from a High-IL shell you cannot simply CreateProcess a Medium-IL
|
||||
# child. The robust, dependency-free way is a one-shot Scheduled Task registered with
|
||||
# RunLevel=Limited + LogonType=Interactive, which lands on the logged-on user's desktop
|
||||
# at their filtered (medium) token. (The classic explorer-shell-injection trick is more
|
||||
# fragile across sessions.)
|
||||
#
|
||||
# Functions:
|
||||
# Start-PtNonElevated -Exe <path> [-Arguments <str>] -> launches a GUI/console exe non-elevated, returns the spawned PID(s)
|
||||
# Invoke-PtNonElevatedCapture -Exe <path> -Arguments <str> -OutFile <path> -> runs a console exe non-elevated, redirects stdout/err to a file, waits, returns the file path
|
||||
#
|
||||
# Verify elevation of the result with Test-ProcessElevated (scripts/pt-admin-probe.ps1).
|
||||
|
||||
function Start-PtNonElevated {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Exe,
|
||||
[string]$Arguments = '',
|
||||
[int]$WaitSeconds = 5,
|
||||
[string]$MatchProcessName # optional: base name to enumerate spawned PIDs (e.g. 'PowerToys.FileLocksmithUI')
|
||||
)
|
||||
if (-not (Test-Path $Exe)) { throw "Exe not found: $Exe" }
|
||||
$taskName = "PtNonElev_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
$before = @()
|
||||
if ($MatchProcessName) { $before = @(Get-Process -Name $MatchProcessName -EA SilentlyContinue | Select-Object -Expand Id) }
|
||||
try {
|
||||
$action = New-ScheduledTaskAction -Execute $Exe -Argument $Arguments
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -RunLevel Limited -LogonType Interactive
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
Start-Sleep -Seconds $WaitSeconds
|
||||
if ($MatchProcessName) {
|
||||
$after = @(Get-Process -Name $MatchProcessName -EA SilentlyContinue | Select-Object -Expand Id)
|
||||
return ($after | Where-Object { $_ -notin $before })
|
||||
}
|
||||
return $null
|
||||
}
|
||||
finally {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PtNonElevatedCapture {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Exe,
|
||||
[string]$Arguments = '',
|
||||
[Parameter(Mandatory)][string]$OutFile,
|
||||
[int]$TimeoutSeconds = 30
|
||||
)
|
||||
if (-not (Test-Path $Exe)) { throw "Exe not found: $Exe" }
|
||||
Remove-Item $OutFile -EA SilentlyContinue
|
||||
$wrap = [IO.Path]::ChangeExtension($OutFile, '.cmd')
|
||||
"`"$Exe`" $Arguments > `"$OutFile`" 2>&1" | Set-Content -Encoding ascii $wrap
|
||||
$taskName = "PtNonElev_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
try {
|
||||
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument "/c `"$wrap`""
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -RunLevel Limited -LogonType Interactive
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
do { Start-Sleep 1; $info = Get-ScheduledTaskInfo -TaskName $taskName }
|
||||
while ($info.LastTaskResult -eq 267009 -and (Get-Date) -lt $deadline) # 267009 = task still running
|
||||
Start-Sleep 1
|
||||
return $OutFile
|
||||
}
|
||||
finally {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue
|
||||
Remove-Item $wrap -EA SilentlyContinue
|
||||
}
|
||||
}
|
||||
94
.github/skills/powertoys-module-verification/scripts/pt-sendinput-chord.ps1
vendored
Normal file
94
.github/skills/powertoys-module-verification/scripts/pt-sendinput-chord.ps1
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
# scripts/pt-sendinput-chord.ps1
|
||||
# Inject a global hotkey chord (e.g. Win+Shift+/) into the system input stream.
|
||||
# Critical: INPUT struct MUST be cb=40 on x64 (with padding for the MOUSEINPUT union member).
|
||||
# The common bug "Win+ hotkeys can't be injected" is a marshaling error producing 32-byte struct
|
||||
# and SendInput returns 0 with GetLastError()==87 (ERROR_INVALID_PARAMETER).
|
||||
#
|
||||
# This SHOULD be a last resort. Prefer Named Events (Invoke-PtSharedEvent) when the module exposes one.
|
||||
# Use this only for: (a) explicit hotkey-trigger verification tests, (b) modules without Named Events,
|
||||
# (c) UI keystrokes inside an already-foreground window (use Send-KeyToHwnd via PostMessage instead
|
||||
# for elevated -> non-elevated AppX, see references/winapp-ui-testing.md).
|
||||
|
||||
if (-not ('PtChord' -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Collections.Generic;
|
||||
public static class PtChord {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct INPUT { public uint type; public KEYBDINPUT ki; public int pad1; public int pad2; } // pad to 40 bytes
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
static extern uint SendInput(uint n, INPUT[] p, int cb);
|
||||
const uint KEYUP = 0x0002;
|
||||
static INPUT K(ushort vk, bool up) { INPUT i=new INPUT(); i.type=1; i.ki.wVk=vk; i.ki.dwFlags=up?KEYUP:0; return i; }
|
||||
public static uint Chord(ushort[] mods, ushort key) {
|
||||
var l=new List<INPUT>();
|
||||
foreach(var m in mods) l.Add(K(m,false));
|
||||
l.Add(K(key,false)); l.Add(K(key,true));
|
||||
for(int i=mods.Length-1;i>=0;i--) l.Add(K(mods[i],true));
|
||||
var a=l.ToArray();
|
||||
return SendInput((uint)a.Length, a, Marshal.SizeOf(typeof(INPUT)));
|
||||
}
|
||||
public static uint Tap(ushort key) { return Chord(new ushort[0], key); }
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
# Common VK codes for chord mods:
|
||||
# LWIN=0x5B RWIN=0x5C CTRL=0x11 SHIFT=0x10 ALT=0x12
|
||||
# Main key VKs:
|
||||
# 0x08 Backspace 0x09 Tab 0x0D Enter 0x1B Escape 0x20 Space
|
||||
# 0x25 Left 0x26 Up 0x27 Right 0x28 Down
|
||||
# 0x30..0x39 0..9 0x41..0x5A A..Z
|
||||
|
||||
function Send-PtChord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Inject a hotkey chord. Returns number of inputs Windows accepted (0 = failed; check GetLastError).
|
||||
.EXAMPLE
|
||||
Send-PtChord -Mods 0x5B,0x10 -Key 0x43 # Win+Shift+C (Color Picker)
|
||||
Send-PtChord -Mods 0x5B,0x11 -Key 0x52 # Win+Ctrl+R (PowerOcr)
|
||||
Send-PtChord -Mods 0x5B,0xA4 -Key 0x20 # Win+Alt+Space (CmdPal default)
|
||||
Send-PtChord -Key 0x0D # plain Enter (no mods)
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[uint16[]]$Mods = @(),
|
||||
[Parameter(Mandatory)][uint16]$Key
|
||||
)
|
||||
$sent = [PtChord]::Chord($Mods, $Key)
|
||||
if ($sent -eq 0) {
|
||||
$err = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
||||
throw "SendInput failed (returned 0, GetLastError=$err). Likely caller is at lower integrity than PT runner, or chord is OS-reserved (Win+L, Win+Tab)."
|
||||
}
|
||||
return $sent
|
||||
}
|
||||
|
||||
function Wait-PtHotkeyAccepted {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
After Send-PtChord, verify the PT runner saw it by tailing its log for the centralized-hook line.
|
||||
Returns the matching log line (if any) within $TimeoutSec.
|
||||
.EXAMPLE
|
||||
Send-PtChord -Mods 0x5B,0x10 -Key 0x43
|
||||
$line = Wait-PtHotkeyAccepted -ModuleHint 'Color' -TimeoutSec 3
|
||||
if (-not $line) { throw "Runner did not log hotkey invocation" }
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([string]$ModuleHint = '', [int]$TimeoutSec = 3)
|
||||
$log = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\PowerToys\RunnerLogs" -Filter 'runner-log_*.log' -EA SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if (-not $log) { return $null }
|
||||
$start = (Get-Date).AddSeconds(-2)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
do {
|
||||
$line = Get-Content $log.FullName -Tail 50 -EA SilentlyContinue |
|
||||
Where-Object { $_ -match 'hotkey is invoked from Centralized keyboard hook' -and ($ModuleHint -eq '' -or $_ -match $ModuleHint) } |
|
||||
Select-Object -Last 1
|
||||
if ($line) { return $line }
|
||||
Start-Sleep -Milliseconds 200
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
return $null
|
||||
}
|
||||
47
.github/skills/powertoys-module-verification/scripts/pt-session-diagnose.ps1
vendored
Normal file
47
.github/skills/powertoys-module-verification/scripts/pt-session-diagnose.ps1
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# pt-session-diagnose.ps1
|
||||
# Diagnose whether the current shell can drive interactive PowerToys tests.
|
||||
# Tells you in one go: am I on the active console session, can I see foreground windows,
|
||||
# and can I use Shell COM. If not, prints the exact psexec mitigation command.
|
||||
|
||||
Add-Type 'using System; using System.Runtime.InteropServices;
|
||||
public class WTS { [DllImport("kernel32.dll")] public static extern uint WTSGetActiveConsoleSessionId(); }
|
||||
public class FG { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); }'
|
||||
|
||||
Write-Host "--- Logged-on users + sessions ---" -ForegroundColor Cyan
|
||||
quser 2>&1
|
||||
|
||||
Write-Host "`n--- This shell's session ---" -ForegroundColor Cyan
|
||||
$me = [Diagnostics.Process]::GetCurrentProcess()
|
||||
" PID: $($me.Id)"
|
||||
" Session: $($me.SessionId)"
|
||||
|
||||
Write-Host "`n--- Console Explorer session(s) ---" -ForegroundColor Cyan
|
||||
$exps = Get-Process explorer -ErrorAction SilentlyContinue
|
||||
if ($exps) {
|
||||
$exps | Select-Object Id, SessionId, @{N='StartTime';E={$_.StartTime}} | Format-Table -AutoSize
|
||||
} else {
|
||||
Write-Host " (no explorer.exe running)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`n--- Windows active console + foreground + Shell COM ---" -ForegroundColor Cyan
|
||||
$activeConsole = [WTS]::WTSGetActiveConsoleSessionId()
|
||||
$fg = [FG]::GetForegroundWindow()
|
||||
$shellOk = $false
|
||||
try { @((New-Object -ComObject Shell.Application).Windows()).Count | Out-Null; $shellOk = $true } catch {}
|
||||
" WTSGetActiveConsoleSessionId() = $activeConsole"
|
||||
" GetForegroundWindow() = $fg"
|
||||
" Shell.Application available = $shellOk"
|
||||
|
||||
Write-Host "`n--- Verdict ---" -ForegroundColor Cyan
|
||||
$consoleSession = ($exps | Select-Object -First 1).SessionId
|
||||
if ($me.SessionId -eq $consoleSession -and $fg -ne 0 -and $shellOk) {
|
||||
Write-Host " PASS - this shell can drive interactive PowerToys tests." -ForegroundColor Green
|
||||
} elseif ($me.SessionId -eq $consoleSession -and $fg -eq 0) {
|
||||
Write-Host " WARN - same session as explorer but no foreground (workstation locked?). Unlock and re-run." -ForegroundColor Yellow
|
||||
} elseif (-not $shellOk) {
|
||||
Write-Host " FAIL - Shell COM unavailable (likely Session 0 / service context). Very few tests possible." -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host " FAIL - shell in Session $($me.SessionId), console explorer in Session $consoleSession. Input injection denied." -ForegroundColor Red
|
||||
Write-Host " Mitigation: relaunch in the console session with:" -ForegroundColor Yellow
|
||||
Write-Host " psexec -accepteula -h -i $consoleSession -s pwsh.exe" -ForegroundColor Yellow
|
||||
}
|
||||
133
.github/skills/powertoys-module-verification/scripts/pt-shared-events.ps1
vendored
Normal file
133
.github/skills/powertoys-module-verification/scripts/pt-shared-events.ps1
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
# scripts/pt-shared-events.ps1
|
||||
# Signal PowerToys modules via Win32 named events.
|
||||
# Catalog source: PowerToys repo src/common/interop/shared_constants.h
|
||||
# (Friendly-name mapping was originally surfaced by community frameworks; the values themselves
|
||||
# are stable PT public IPC names. This file is self-contained — no external repo required.)
|
||||
# Reason: instead of pressing a hotkey (which is racey, foreground-sensitive, and UIPI-fragile),
|
||||
# directly SetEvent on the kernel event the module is waiting on. Same code path as the hotkey.
|
||||
|
||||
if (-not ('PtEv' -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class PtEv {
|
||||
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
||||
private static extern IntPtr OpenEventW(uint dwAccess, bool bInherit, string lpName);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
private static extern bool SetEvent(IntPtr h);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
private static extern bool CloseHandle(IntPtr h);
|
||||
private const uint EVENT_MODIFY_STATE = 0x0002;
|
||||
private const uint SYNCHRONIZE = 0x00100000;
|
||||
|
||||
public static bool Signal(string fullName) {
|
||||
IntPtr h = OpenEventW(EVENT_MODIFY_STATE | SYNCHRONIZE, false, fullName);
|
||||
if (h == IntPtr.Zero) {
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
throw new System.ComponentModel.Win32Exception(err,
|
||||
"OpenEvent failed for '" + fullName + "' (err=" + err + "). Owning module process may not be running.");
|
||||
}
|
||||
try { return SetEvent(h); } finally { CloseHandle(h); }
|
||||
}
|
||||
|
||||
public static bool Exists(string fullName) {
|
||||
IntPtr h = OpenEventW(SYNCHRONIZE, false, fullName);
|
||||
if (h == IntPtr.Zero) return false;
|
||||
CloseHandle(h); return true;
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
# Friendly-name -> full event name map (per Local\ namespace).
|
||||
# Source: <PT-repo>\src\common\interop\shared_constants.h
|
||||
$script:PtSharedEvents = @{
|
||||
# ── Hotkey-activated module triggers ──
|
||||
'AOT.Pin' = 'Local\AlwaysOnTopPinEvent-892e0aa2-cfa8-4cc4-b196-ddeb32314ce8'
|
||||
'AOT.IncreaseOpacity' = 'Local\AlwaysOnTopIncreaseOpacityEvent-a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
'AOT.DecreaseOpacity' = 'Local\AlwaysOnTopDecreaseOpacityEvent-b2c3d4e5-f6a7-8901-bcde-f12345678901'
|
||||
'AdvancedPaste.ShowUI' = 'Local\PowerToys_AdvancedPaste_ShowUI'
|
||||
'CmdPal.Show' = 'Local\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a'
|
||||
'ColorPicker.Show' = 'Local\ShowColorPickerEvent-8c46be2a-3e05-4186-b56b-4ae986ef2525'
|
||||
'CropAndLock.Reparent' = 'Local\PowerToysCropAndLockReparentEvent-6060860a-76a1-44e8-8d0e-6355785e9c36'
|
||||
'CropAndLock.Thumbnail' = 'Local\PowerToysCropAndLockThumbnailEvent-1637be50-da72-46b2-9220-b32b206b2434'
|
||||
'CursorWrap.Trigger' = 'Local\CursorWrapTriggerEvent-1f8452b5-4e6e-45b3-8b09-13f14a5900c9'
|
||||
'EnvVars.Show' = 'Local\PowerToysEnvironmentVariables-ShowEnvironmentVariablesEvent-1021f616-e951-4d64-b231-a8f972159978'
|
||||
'EnvVars.ShowAdmin' = 'Local\PowerToysEnvironmentVariables-EnvironmentVariablesAdminEvent-8c95d2ad-047c-49a2-9e8b-b4656326cfb2'
|
||||
'FancyZones.ToggleEditor' = 'Local\FancyZones-ToggleEditorEvent-1e174338-06a3-472b-874d-073b21c62f14'
|
||||
'FindMyMouse.Trigger' = 'Local\FindMyMouseTriggerEvent-5a9dc5f4-1c74-4f2f-a66f-1b9b6a2f9b23'
|
||||
'Hosts.Show' = 'Local\Hosts-ShowHostsEvent-5a0c0aae-5ff5-40f5-95c2-20e37ed671f0'
|
||||
'Hosts.ShowAdmin' = 'Local\Hosts-ShowHostsAdminEvent-60ff44e2-efd3-43bf-928a-f4d269f98bec'
|
||||
'LightSwitch.Toggle' = 'Local\PowerToys-LightSwitch-ToggleEvent-d8dc2f29-8c94-4ca1-8c5f-3e2b1e3c4f5a'
|
||||
'LightSwitch.Light' = 'Local\PowerToysLightSwitch-LightThemeEvent-50077121-2ffc-4841-9c86-ab1bd3f9baca'
|
||||
'LightSwitch.Dark' = 'Local\PowerToysLightSwitch-DarkThemeEvent-b3a835c0-eaa2-49b0-b8eb-f793e3df3368'
|
||||
'MeasureTool.Trigger' = 'Local\MeasureToolEvent-3d46745f-09b3-4671-a577-236be7abd199'
|
||||
'MouseCrosshairs.Trigger' = 'Local\MouseCrosshairsTriggerEvent-0d4c7f92-0a5c-4f5c-b64b-8a2a2f7e0b21'
|
||||
'MouseHighlighter.Trigger' = 'Local\MouseHighlighterTriggerEvent-1e3c9c3d-3fdf-4f9a-9a52-31c9b3c3a8f4'
|
||||
'MouseJump.Show' = 'Local\MouseJumpEvent-aa0be051-3396-4976-b7ba-1a9cc7d236a5'
|
||||
'NewKeyboardManager.Open' = 'Local\PowerToysOpenNewKeyboardManagerEvent-9c1d2e3f-4b5a-6c7d-8e9f-0a1b2c3d4e5f'
|
||||
'Peek.Show' = 'Local\ShowPeekEvent'
|
||||
'PowerDisplay.Toggle' = 'Local\PowerToysPowerDisplay-ToggleEvent-5f1a9c3e-7d2b-4e8f-9a6c-3b5d7e9f1a2c'
|
||||
'PowerLauncher.Invoke' = 'Local\PowerToysRunInvokeEvent-30f26ad7-d36d-4c0e-ab02-68bb5ff3c4ab'
|
||||
'PowerOcr.Show' = 'Local\PowerOCREvent-dc864e06-e1af-4ecc-9078-f98bee745e3a'
|
||||
'RegistryPreview.Trigger' = 'Local\RegistryPreviewEvent-4C559468-F75A-4E7F-BC4F-9C9688316687'
|
||||
'ShortcutGuide.Trigger' = 'Local\ShortcutGuide-TriggerEvent-d4275ad3-2531-4d19-9252-c0becbd9b496'
|
||||
'TextExtractor.Show' = 'Local\PowerOCREvent-dc864e06-e1af-4ecc-9078-f98bee745e3a'
|
||||
'Workspaces.Hotkey' = 'Local\PowerToys-Workspaces-HotkeyEvent-2625C3C8-BAC9-4DB3-BCD6-3B4391A26FD0'
|
||||
'Workspaces.LaunchEditor' = 'Local\Workspaces-LaunchEditorEvent-a55ff427-cf62-4994-a2cd-9f72139296bf'
|
||||
'ZoomIt.Zoom' = 'Local\PowerToysZoomIt-ZoomEvent-1e4190d7-94bc-4ad5-adc0-9a8fd07cb393'
|
||||
'ZoomIt.Draw' = 'Local\PowerToysZoomIt-DrawEvent-56338997-404d-4549-bd9a-d132b6766975'
|
||||
'ZoomIt.Break' = 'Local\PowerToysZoomIt-BreakEvent-17f2e63c-4c56-41dd-90a0-2d12f9f50c6b'
|
||||
'ZoomIt.LiveZoom' = 'Local\PowerToysZoomIt-LiveZoomEvent-390bf0c7-616f-47dc-bafe-a2d228add20d'
|
||||
'ZoomIt.Snip' = 'Local\PowerToysZoomIt-SnipEvent-2fd9c211-436d-4f17-a902-2528aaae3e30'
|
||||
'ZoomIt.SnipOcr' = 'Local\PowerToysZoomIt-SnipOcrEvent-a7c3b1d2-9e4f-4a6b-8d5c-1f2e3a4b5c6d'
|
||||
'ZoomIt.Record' = 'Local\PowerToysZoomIt-RecordEvent-74539344-eaad-4711-8e83-23946e424512'
|
||||
|
||||
# ── Termination triggers (clean shutdown without process kill) ──
|
||||
'AOT.Terminate' = 'Local\AlwaysOnTopTerminateEvent-cfdf1eae-791f-4953-8021-2f18f3837eae'
|
||||
'Awake.Exit' = 'Local\PowerToysAwakeExitEvent-c0d5e305-35fc-4fb5-83ec-f6070cfaf7fe'
|
||||
'CmdPal.Exit' = 'Local\PowerToysCmdPal-ExitEvent-eb73f6be-3f22-4b36-aee3-62924ba40bfd'
|
||||
'ColorPicker.Terminate' = 'Local\TerminateColorPickerEvent-3d676258-c4d5-424e-a87a-4be22020e813'
|
||||
'CropAndLock.Exit' = 'Local\PowerToysCropAndLockExitEvent-d995d409-7b70-482b-bad6-e7c8666f375a'
|
||||
'FZE.Exit' = 'Local\PowerToys-FZE-ExitEvent-ca8c73de-a52c-4274-b691-46e9592d3b43'
|
||||
'Hosts.Terminate' = 'Local\Hosts-TerminateHostsEvent-d5410d5e-45a6-4d11-bbf0-a4ec2d064888'
|
||||
'KBM.Terminate' = 'Local\TerminateKBMSharedEvent-a787c967-55b6-47de-94d9-56f39fed839e'
|
||||
'MouseJump.Terminate' = 'Local\TerminateMouseJumpEvent-252fa337-317f-4c37-a61f-99464c3f9728'
|
||||
'Peek.Terminate' = 'Local\TerminatePeekEvent-267149fe-7ed2-427d-a3ad-9e18203c037c'
|
||||
'PowerAccent.Exit' = 'Local\PowerToysPowerAccentExitEvent-53e93389-d19a-4fbb-9b36-1981c8965e17'
|
||||
'PowerOcr.Terminate' = 'Local\TerminatePowerOCREvent-08e5de9d-15df-4ea8-8840-487c13435a67'
|
||||
'PowerDisplay.Terminate' = 'Local\PowerToysPowerDisplay-TerminateEvent-7b9c2e1f-8a5d-4c3e-9f6b-2a1d8c5e3b7a'
|
||||
'Run.Exit' = 'Local\PowerToysRunExitEvent-3e38e49d-a762-4ef1-88f2-fd4bc7481516'
|
||||
'ShortcutGuide.Exit' = 'Local\ShortcutGuide-ExitEvent-35697cdd-a3d2-47d6-a246-34efcc73eac0'
|
||||
'Settings.Terminate' = 'Local\PowerToysRunnerTerminateSettingsEvent-c34cb661-2e69-4613-a1f8-4e39c25d7ef6'
|
||||
'ZoomIt.Exit' = 'Local\PowerToysZoomIt-ExitEvent-36641ce6-df02-4eac-abea-a3fbf9138220'
|
||||
'GrabAndMove.Exit' = 'Local\PowerToysGrabAndMove-ExitEvent-b8c4d2e3-5f6a-7b8c-9d0e-1f2a3b4c5d6e'
|
||||
}
|
||||
|
||||
function Invoke-PtSharedEvent {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Signal a PowerToys named kernel event by friendly name (e.g. 'CmdPal.Show')
|
||||
or by full event path (e.g. 'Local\PowerToys_AdvancedPaste_ShowUI').
|
||||
Returns $true on success; throws if event doesn't exist or owner not running.
|
||||
.EXAMPLE
|
||||
Invoke-PtSharedEvent -Name 'CmdPal.Show'
|
||||
Invoke-PtSharedEvent -Name 'PowerLauncher.Invoke'
|
||||
Invoke-PtSharedEvent -Name 'AOT.Pin'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
$eventName = if ($script:PtSharedEvents.ContainsKey($Name)) { $script:PtSharedEvents[$Name] } else { $Name }
|
||||
return [PtEv]::Signal($eventName)
|
||||
}
|
||||
|
||||
function Test-PtSharedEvent {
|
||||
[CmdletBinding()] param([Parameter(Mandatory)][string]$Name)
|
||||
$eventName = if ($script:PtSharedEvents.ContainsKey($Name)) { $script:PtSharedEvents[$Name] } else { $Name }
|
||||
return [PtEv]::Exists($eventName)
|
||||
}
|
||||
|
||||
function Get-PtSharedEventCatalog {
|
||||
$script:PtSharedEvents.GetEnumerator() | Sort-Object Name |
|
||||
ForEach-Object { [pscustomobject]@{ Name = $_.Key; Event = $_.Value } }
|
||||
}
|
||||
66
.github/skills/powertoys-module-verification/scripts/pt-shell-verbs.ps1
vendored
Normal file
66
.github/skills/powertoys-module-verification/scripts/pt-shell-verbs.ps1
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# scripts/pt-shell-verbs.ps1
|
||||
# Enumerate Windows classic shell verbs (HKCR-registered) via Shell.Application COM.
|
||||
#
|
||||
# SCOPE WARNING: this does NOT find PowerToys context-menu items on Win11. PT registers
|
||||
# PowerRename, Image Resizer, File Locksmith, New+ etc. via IExplorerCommand (Tier-1 modern
|
||||
# menu), which is invisible to Shell.Application.Verbs(). For PT-context-menu drives, use
|
||||
# `pt-explorer-contextmenu.ps1` (synthetic right-click + UIA invoke). See
|
||||
# `explorer-context-menu-flow.md` for the canonical pattern.
|
||||
#
|
||||
# Useful for: enumerating non-PT classic verbs (Open, Edit, Send-to, third-party shell extensions),
|
||||
# and as a negative check that PT verbs are NOT classic-shadowed.
|
||||
|
||||
function Get-PtShellVerbs {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enumerate classic HKCR shell verbs on a file or folder. Returns Name + the underlying Verb COM object.
|
||||
.EXAMPLE
|
||||
Get-PtShellVerbs -Path 'D:\fixtures\image.png' | Format-Table Name
|
||||
#>
|
||||
[CmdletBinding()] param([Parameter(Mandatory)][string]$Path)
|
||||
if (-not (Test-Path $Path)) { throw "Path not found: $Path" }
|
||||
$abs = (Resolve-Path $Path).Path
|
||||
$folder = Split-Path -Parent $abs
|
||||
$leaf = Split-Path -Leaf $abs
|
||||
$shell = New-Object -ComObject Shell.Application
|
||||
$ns = $shell.NameSpace($folder)
|
||||
if (-not $ns) { throw "Cannot open folder namespace: $folder" }
|
||||
$item = $ns.ParseName($leaf)
|
||||
if (-not $item) { throw "File not in folder: $leaf" }
|
||||
return @($item.Verbs()) | ForEach-Object {
|
||||
[pscustomobject]@{ Name = $_.Name; Verb = $_ }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PtShellVerb {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Invoke a classic shell verb on a file by name-regex match. Returns $true on success.
|
||||
Does NOT work for PT Win11 modern-menu items — see SCOPE WARNING at top.
|
||||
.EXAMPLE
|
||||
Invoke-PtShellVerb -Path 'D:\fixtures\img.png' -NamePattern '^Edit$'
|
||||
#>
|
||||
[CmdletBinding()] param(
|
||||
[Parameter(Mandatory)][string]$Path,
|
||||
[Parameter(Mandatory)][string]$NamePattern
|
||||
)
|
||||
$verb = Get-PtShellVerbs -Path $Path | Where-Object { $_.Name -match $NamePattern } | Select-Object -First 1
|
||||
if (-not $verb) {
|
||||
Write-Warning "No classic shell verb matching '$NamePattern' on '$Path'. (Win11 PT modern-menu items are NOT visible here — use pt-explorer-contextmenu.ps1 instead.)"
|
||||
return $false
|
||||
}
|
||||
$verb.Verb.DoIt()
|
||||
return $true
|
||||
}
|
||||
|
||||
function Reset-PtShellComCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Release current Shell.Application COM instance + force a fresh one on next call.
|
||||
Use when you've installed/registered a shell handler mid-test and the cached verb list
|
||||
still reflects the old state.
|
||||
#>
|
||||
[System.Runtime.InteropServices.Marshal]::CleanupUnusedObjectsInCurrentContext()
|
||||
[System.GC]::Collect()
|
||||
[System.GC]::WaitForPendingFinalizers()
|
||||
}
|
||||
111
.github/skills/powertoys-module-verification/scripts/pt-state.ps1
vendored
Normal file
111
.github/skills/powertoys-module-verification/scripts/pt-state.ps1
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
# scripts/pt-state.ps1
|
||||
# Common state-verification helpers: settings.json diff, runner log grep, GPO log check,
|
||||
# process spawn detection, AppX probe.
|
||||
|
||||
function Get-PtSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Read the master PT settings.json (enabled.<Module> flags + run_elevated + theme + language).
|
||||
#>
|
||||
$f = "$env:LOCALAPPDATA\Microsoft\PowerToys\settings.json"
|
||||
if (-not (Test-Path $f)) { return $null }
|
||||
Get-Content $f -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-PtModuleSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Read a single module's settings.json (e.g. AdvancedPaste, FancyZones, etc.).
|
||||
These ARE auto-reloaded by the per-module file watcher (~3s debounce).
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$ModuleDir)
|
||||
$f = "$env:LOCALAPPDATA\Microsoft\PowerToys\$ModuleDir\settings.json"
|
||||
if (-not (Test-Path $f)) { return $null }
|
||||
Get-Content $f -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-CmdPalSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Read CmdPal AppX settings.json (sandboxed path). Contains 19 ProviderSettings, DockSettings,
|
||||
GalleryFeedUrl, EscapeKeyBehaviorSetting, AutoGoHomeInterval, Hotkey, Aliases, etc.
|
||||
#>
|
||||
$f = "$env:LOCALAPPDATA\Packages\Microsoft.CommandPalette_8wekyb3d8bbwe\LocalState\settings.json"
|
||||
if (-not (Test-Path $f)) { return $null }
|
||||
Get-Content $f -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-PtRunnerLogTail {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tail the latest runner-log_<date>.log file for matching lines.
|
||||
.EXAMPLE
|
||||
Get-PtRunnerLogTail -Pattern 'hotkey is invoked' -TailLines 100
|
||||
Get-PtRunnerLogTail -Pattern 'GPO sets' -TailLines 50
|
||||
#>
|
||||
param([string]$Pattern = '.*', [int]$TailLines = 50)
|
||||
$log = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\PowerToys\RunnerLogs" -Filter 'runner-log_*.log' -EA SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if (-not $log) { return @() }
|
||||
Get-Content $log.FullName -Tail $TailLines -EA SilentlyContinue | Where-Object { $_ -match $Pattern }
|
||||
}
|
||||
|
||||
function Test-PtModuleEnabled {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check whether a specific module is enabled in master settings.json.
|
||||
Note: PT Run uses the key "PowerToys Run" (with space).
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$ModuleKey)
|
||||
$s = Get-PtSettings
|
||||
if (-not $s) { return $false }
|
||||
return [bool]$s.enabled.$ModuleKey
|
||||
}
|
||||
|
||||
function Test-PtModuleProcess {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Return the process(es) for a module exe name (e.g. 'PowerToys.AdvancedPaste').
|
||||
Returns empty array if not running.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$ExeName)
|
||||
@(Get-Process $ExeName -EA SilentlyContinue)
|
||||
}
|
||||
|
||||
function Restart-PtRunner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Kill the runner and relaunch to force fresh load of master settings.json.
|
||||
The runner does NOT auto-pickup edits to the top-level enabled.<Module> flags.
|
||||
#>
|
||||
$pt = Get-Process PowerToys -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($pt) { Stop-Process -Id $pt.Id -Force; Start-Sleep -Milliseconds 800 }
|
||||
Start-Process "$env:LOCALAPPDATA\PowerToys\PowerToys.exe"
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
|
||||
function Backup-PtModuleSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Snapshot a module's settings.json to TEMP for restore-on-exit. Returns the backup path.
|
||||
.EXAMPLE
|
||||
$bk = Backup-PtModuleSettings -ModuleDir AdvancedPaste
|
||||
try { ... mutate ... } finally { Restore-PtModuleSettings -ModuleDir AdvancedPaste -BackupPath $bk }
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$ModuleDir)
|
||||
$src = "$env:LOCALAPPDATA\Microsoft\PowerToys\$ModuleDir\settings.json"
|
||||
if (-not (Test-Path $src)) { return $null }
|
||||
$bk = Join-Path $env:TEMP ("ptbk-$ModuleDir-$(Get-Random -Maximum 9999).json")
|
||||
Copy-Item -Path $src -Destination $bk -Force
|
||||
return $bk
|
||||
}
|
||||
|
||||
function Restore-PtModuleSettings {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ModuleDir,
|
||||
[Parameter(Mandatory)][string]$BackupPath
|
||||
)
|
||||
$dst = "$env:LOCALAPPDATA\Microsoft\PowerToys\$ModuleDir\settings.json"
|
||||
Copy-Item -Path $BackupPath -Destination $dst -Force
|
||||
Remove-Item $BackupPath -Force -EA SilentlyContinue
|
||||
}
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -21,6 +21,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
issue: ${{ fromJson(github.event.inputs.issue_numbers) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run GenAI Issue Deduplicator
|
||||
uses: pelikhan/action-genai-issue-dedup@v0
|
||||
|
||||
67
.github/workflows/regenerate-devdocs-website.yml
vendored
Normal file
67
.github/workflows/regenerate-devdocs-website.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
# Builds the Dev Docs website from doc/devdocs with docmd and publishes it to GitHub Pages.
|
||||
#
|
||||
# The generated site is uploaded as a Pages artifact and deployed directly. It is never
|
||||
# committed to the repo, so doc/devdocs-website/site stays untracked (see .gitignore).
|
||||
#
|
||||
# Requires GitHub Pages to be enabled with "Source: GitHub Actions" under the repository
|
||||
# Settings -> Pages.
|
||||
name: Publish Dev Docs Website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'doc/devdocs/**'
|
||||
- 'doc/devdocs-website/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one Pages deployment at a time and let an in-progress deploy finish.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# Full history so docmd's git plugin can resolve per-page "last updated" dates.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Build static site with docmd
|
||||
working-directory: doc/devdocs-website
|
||||
# docmd is pinned in package.json; dependencies are installed fresh each run.
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: doc/devdocs-website/site
|
||||
# v4+ excludes dotfiles by default; keep docmd's generated .nojekyll.
|
||||
include-hidden-files: true
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
2
.github/workflows/telemetry-pr-check.yml
vendored
2
.github/workflows/telemetry-pr-check.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Detect telemetry event changes and comment PR
|
||||
env:
|
||||
|
||||
@@ -656,7 +656,10 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/launcher/Tests/">
|
||||
<File Path="src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj" />
|
||||
<Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
@@ -1004,6 +1007,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/ShortcutGuide/ShortcutGuide.UnitTests/ShortcutGuide.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj" Id="e487304a-b1fb-4e6b-8e70-014051af5b99" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/Workspaces/">
|
||||
|
||||
2
doc/devdocs-website/.gitignore
vendored
Normal file
2
doc/devdocs-website/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# docmd build output — published to GitHub Pages by CI, not committed.
|
||||
site/
|
||||
3
doc/devdocs-website/.npmrc
Normal file
3
doc/devdocs-website/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
# No lockfile: docmd is pinned in package.json and installed fresh on every build
|
||||
# (locally and in CI), so a tracked package-lock.json is unnecessary here.
|
||||
package-lock=false
|
||||
44
doc/devdocs-website/README.md
Normal file
44
doc/devdocs-website/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Dev Docs Website
|
||||
|
||||
This folder hosts the [docmd](https://docmd.io/) project that turns the PowerToys developer
|
||||
documentation in [`doc/devdocs`](../devdocs) into a static website.
|
||||
|
||||
## Generated site
|
||||
|
||||
The `site/` folder is the docmd build output. It is **not committed** to the repository — it is
|
||||
git-ignored and rebuilt on demand. You only need it locally when previewing your changes (see below).
|
||||
|
||||
Publishing is handled by the
|
||||
[Publish Dev Docs Website](../../.github/workflows/regenerate-devdocs-website.yml) GitHub Action, which
|
||||
runs whenever files under `doc/devdocs` (or this folder) change on the `main` branch — it can also
|
||||
be triggered manually from the **Actions** tab. The action builds the site and deploys it straight to
|
||||
GitHub Pages as an artifact, so nothing is written back to the repository.
|
||||
|
||||
> [!NOTE]
|
||||
> The action requires GitHub Pages to be enabled with **Source: GitHub Actions** under the repository
|
||||
> **Settings → Pages**.
|
||||
|
||||
## Editing the docs
|
||||
|
||||
To change the documentation, edit the Markdown files under [`doc/devdocs`](../devdocs). The remaining
|
||||
files in this folder are maintained by hand and are safe to edit:
|
||||
|
||||
- `docmd.config.json` — docmd configuration (title, source, output, plugins)
|
||||
- `package.json` — pins the docmd version used to build the site
|
||||
- `docmd-plugins/` — local build-time docmd plugins
|
||||
|
||||
> [!TIP]
|
||||
> Link to repository files with repo-root-relative paths such as `/src/modules/.../Foo.cpp`.
|
||||
> VS Code resolves these against the workspace root (so they open the local file), and the
|
||||
> bundled `github-source-links` plugin rewrites them to
|
||||
> `https://github.com/microsoft/PowerToys/blob/main/...` on the published site.
|
||||
|
||||
## Building locally
|
||||
|
||||
Requires [Node.js](https://nodejs.org/).
|
||||
|
||||
```powershell
|
||||
npm install # install dependencies (first time only)
|
||||
npm run dev # start a local preview server at http://localhost:3000
|
||||
npm run build # generate the static site into ./site
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
// docmd plugin: github-source-links
|
||||
//
|
||||
// The dev docs link to source files with repo-root-relative paths such as
|
||||
// "/src/modules/.../Foo.cpp". VS Code resolves those against the workspace root,
|
||||
// so they stay clickable while editing locally. On the published static site,
|
||||
// however, a "/src/..." link resolves against the site origin and 404s.
|
||||
//
|
||||
// This plugin rewrites those links to absolute GitHub blob URLs so they work on
|
||||
// the published site, while the Markdown sources stay untouched (keeping local
|
||||
// VS Code navigation intact).
|
||||
//
|
||||
// It hooks markdownSetup at the Markdown token level, so it only rewrites links
|
||||
// written in the docs' content. docmd's own generated links (sidebar, breadcrumbs,
|
||||
// canonical tags) are never seen here, which matters because an internal doc route
|
||||
// like "/tools/build-tools" is otherwise indistinguishable from a repo path like
|
||||
// "/tools/BugReportTool" once rendered to HTML.
|
||||
//
|
||||
// docmd appends a trailing slash to the rewritten links (".../Foo.cpp/"); GitHub
|
||||
// resolves that to the file anyway, so it is left as-is for simplicity.
|
||||
|
||||
const REPO_BLOB_BASE = 'https://github.com/microsoft/PowerToys/blob/main';
|
||||
|
||||
export default {
|
||||
plugin: {
|
||||
name: 'github-source-links',
|
||||
version: '1.0.0',
|
||||
capabilities: ['markdown'],
|
||||
},
|
||||
|
||||
markdownSetup(md) {
|
||||
const defaultRender =
|
||||
md.renderer.rules.link_open ||
|
||||
((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options));
|
||||
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx];
|
||||
const hrefIndex = token.attrIndex('href');
|
||||
|
||||
if (hrefIndex >= 0) {
|
||||
const href = token.attrs[hrefIndex][1];
|
||||
|
||||
// Only repo-root-relative links ("/src/..."). Leave protocol-relative
|
||||
// ("//host"), absolute ("https://..."), relative and anchor links alone.
|
||||
if (href.length > 1 && href[0] === '/' && href[1] !== '/') {
|
||||
token.attrs[hrefIndex][1] = REPO_BLOB_BASE + href;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "docmd-plugin-github-source-links",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"description": "docmd plugin that rewrites repo-root-relative doc links to GitHub blob URLs at build time."
|
||||
}
|
||||
9
doc/devdocs-website/docmd.config.json
Normal file
9
doc/devdocs-website/docmd.config.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "PowerToys Dev Docs",
|
||||
"src": "../devdocs",
|
||||
"out": "site",
|
||||
"base": "/",
|
||||
"plugins": {
|
||||
"docmd-plugin-github-source-links": {}
|
||||
}
|
||||
}
|
||||
16
doc/devdocs-website/package.json
Normal file
16
doc/devdocs-website/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "powertoys-devdocs-website",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Static Dev Docs website generated from doc/devdocs with docmd.",
|
||||
"scripts": {
|
||||
"dev": "docmd dev",
|
||||
"build": "docmd build"
|
||||
},
|
||||
"dependencies": {
|
||||
"docmd-plugin-github-source-links": "file:./docmd-plugins/github-source-links"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docmd/core": "0.8.6"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.2 KiB |
@@ -1,19 +0,0 @@
|
||||
# Backlog
|
||||
|
||||
This file captures the prioritized list of issues the FancyZones team will tackle
|
||||
|
||||
## On deck
|
||||
|
||||
## Backlog
|
||||
Add tests to the new editor [197](https://github.com/microsoft/PowerToys/issues/197)
|
||||
Cycle through windows in a Zone [175](https://github.com/microsoft/PowerToys/issues/175)
|
||||
Minimize/restore windows in a zone as a group [174](https://github.com/microsoft/PowerToys/issues/174)
|
||||
FancyZones should support custom layouts for different "environments" [177](https://github.com/microsoft/PowerToys/issues/177)
|
||||
Win+arrow is directional based on zone rect [162](https://github.com/microsoft/PowerToys/issues/162)
|
||||
Dragging a zoned window should restore size to a checkpointed size instead of current rect [166](https://github.com/microsoft/PowerToys/issues/166)
|
||||
FancyZones should merge with MTND and include zone moves in the pop-up [178](https://github.com/microsoft/PowerToys/issues/178)
|
||||
Drag to edge of screen automatically switches virtual desktops [168](https://github.com/microsoft/PowerToys/issues/168)
|
||||
Visual updates for Win+Arrow [171](https://github.com/microsoft/PowerToys/issues/171)
|
||||
Add "magnetic dragging and resizing" mode to FancyZones [181](https://github.com/microsoft/PowerToys/issues/181)
|
||||
Create layout from current windows [159](https://github.com/microsoft/PowerToys/issues/159)
|
||||
Zone sets that have a dynamic number of zones [160](https://github.com/microsoft/PowerToys/issues/160)
|
||||
@@ -1,24 +0,0 @@
|
||||
# PowerToys Backlog
|
||||
|
||||
The list below is the set of utilities we're considering and the rough priority order of the utilities. If you have feedback on the order of the utilities, please use the issues for each one to provide that feedback. Note that new features for existing utilities (dock / undock zone layouts for FancyZones) are tracked in the backlog for each utility.
|
||||
|
||||
## On deck
|
||||
|
||||
* Maximize to new desktop widget - The MTND widget shows a pop-up button when a user hovers over the maximize / restore button on any window. Clicking it creates a new desktop, sends the app to that desktop and maximizes the app on the new desktop.
|
||||
* [Process terminate tool](https://github.com/indierawk2k2/PowerToys-1/blob/master/specs/Terminate%20Spec.md)
|
||||
* [Animated gif screen recorder](https://github.com/indierawk2k2/PowerToys-1/blob/master/specs/GIF%20Maker%20Spec.md)
|
||||
|
||||
## Backlog
|
||||
|
||||
Please use issues and votes to guide the project to suggest new ideas and help us prioritize the list below.
|
||||
|
||||
1. [Keyboard shortcut manager](https://github.com/microsoft/PowerToys/issues/6)
|
||||
2. [Win+R replacement](https://github.com/microsoft/PowerToys/issues/44)
|
||||
3. Resource use tool (maps between a resource like a file handle to an app and vice-versa)
|
||||
4. Performance analysis over time to track which processes have been slowing down your machine
|
||||
5. Better Alt+Tab including browser tab integration and search for running apps
|
||||
6. [Battery tracker](https://github.com/microsoft/PowerToys/issues/7)
|
||||
7. [Quick resolution swaps in taskbar](https://github.com/microsoft/PowerToys/issues/27)
|
||||
8. Mouse events without focus
|
||||
9. Cmd (or PS or Bash) from here
|
||||
10. Contents menu file browsing
|
||||
@@ -1,13 +0,0 @@
|
||||
# Backlog
|
||||
|
||||
This file captures the prioritized list of issues for the Windows key shortcut guide
|
||||
|
||||
## On deck
|
||||
Windows key shortcut guide animation performance is choppy [198](https://github.com/microsoft/PowerToys/issues/198)
|
||||
Shortcut guide strings should be localized [199](https://github.com/microsoft/PowerToys/issues/199)
|
||||
|
||||
## Backlog
|
||||
Add Win+Shift+S to the WKSG (screenshot tool) [179](https://github.com/microsoft/PowerToys/issues/179)
|
||||
Replace SVG with software-generated content. [156](https://github.com/microsoft/PowerToys/issues/156)
|
||||
Shortcut sorting [154](https://github.com/microsoft/PowerToys/issues/154)
|
||||
Make shortcut descriptors clickable [152](https://github.com/microsoft/PowerToys/issues/152)
|
||||
@@ -1,114 +0,0 @@
|
||||
---
|
||||
last-update: 1-18-2026
|
||||
---
|
||||
|
||||
# PowerToys Awake Changelog
|
||||
|
||||
## Builds
|
||||
|
||||
The build ID can be found in `Core\Constants.cs` in the `BuildId` variable - it is a unique identifier for the current builds that allows better diagnostics (we can look up the build ID from the logs) and offers a way to triage Awake-specific issues faster independent of the PowerToys version. The build ID does not carry any significance beyond that within the PowerToys code base.
|
||||
|
||||
The build ID moniker is made up of two components - a reference to a [Halo](https://en.wikipedia.org/wiki/Halo_(franchise)) character, and the date when the work on the specific build started in the format of `MMDDYYYY`.
|
||||
|
||||
| Build ID | Build Date |
|
||||
|:-------------------------------------------------------------------|:------------------|
|
||||
| [`DIDACT_01182026`](#DIDACT_01182026-january-18-2026) | January 18, 2026 |
|
||||
| [`TILLSON_11272024`](#TILLSON_11272024-november-27-2024) | November 27, 2024 |
|
||||
| [`PROMETHEAN_09082024`](#PROMETHEAN_09082024-september-8-2024) | September 8, 2024 |
|
||||
| [`VISEGRADRELAY_08152024`](#VISEGRADRELAY_08152024-august-15-2024) | August 15, 2024 |
|
||||
| [`DAISY023_04102024`](#DAISY023_04102024-april-10-2024) | April 10, 2024 |
|
||||
| [`ATRIOX_04132023`](#ATRIOX_04132023-april-13-2023) | April 13, 2023 |
|
||||
| [`LIBRARIAN_03202022`](#librarian_03202022-march-20-2022) | March 20, 2022 |
|
||||
| `ARBITER_01312022` | January 31, 2022 |
|
||||
|
||||
### `DIDACT_01182026` (January 18, 2026)
|
||||
|
||||
>[!NOTE]
|
||||
>See pull request: [Awake - `DIDACT_01182026`](https://github.com/microsoft/PowerToys/pull/44795)
|
||||
|
||||
- [#32544](https://github.com/microsoft/PowerToys/issues/32544) Fixed an issue where Awake settings became non-functional after the PC wakes from sleep. Added `WM_POWERBROADCAST` handling to detect system resume events (`PBT_APMRESUMEAUTOMATIC`, `PBT_APMRESUMESUSPEND`) and re-apply `SetThreadExecutionState` to restore the awake state.
|
||||
- [#36150](https://github.com/microsoft/PowerToys/issues/36150) Fixed an issue where Awake would not prevent sleep when AC power is connected. Added `PBT_APMPOWERSTATUSCHANGE` handling to re-apply `SetThreadExecutionState` when the power source changes (AC/battery transitions).
|
||||
- Fixed an issue where toggling "Keep screen on" during an active timed session would disrupt the countdown timer. The display setting now updates directly without restarting the timer, preserving the exact remaining time.
|
||||
- [#41918](https://github.com/microsoft/PowerToys/issues/41918) Fixed `WM_COMMAND` message processing flaw in `TrayHelper.WndProc` that incorrectly compared enum values against enum count. Added proper bounds checking for custom tray time entries.
|
||||
- Investigated [#44134](https://github.com/microsoft/PowerToys/issues/44134) - documented that `ES_DISPLAY_REQUIRED` (used when "Keep display on" is enabled) blocks Task Scheduler idle detection, preventing scheduled maintenance tasks like SSD TRIM. Workaround: disable "Keep display on" or manually run `Optimize-Volume -DriveLetter C -ReTrim`. Additional investigation needed for potential "idle window" feature.
|
||||
- [#41738](https://github.com/microsoft/PowerToys/issues/41738) Fixed `--display-on` CLI flag default from `true` to `false` to align with documentation and PowerToys settings behavior. This is a breaking change for scripts relying on the undocumented default.
|
||||
- [#41674](https://github.com/microsoft/PowerToys/issues/41674) Fixed silent failure when `SetThreadExecutionState` fails. The monitor thread now handles the return value, logs an error, and reverts to passive mode with updated tray icon.
|
||||
- [#38770](https://github.com/microsoft/PowerToys/issues/38770) Fixed tray icon failing to appear after Windows updates. Increased retry attempts and delays for icon Add operations (10 attempts, up to ~15.5 seconds total) while keeping existing fast retry behavior for Update/Delete operations.
|
||||
- [#40501](https://github.com/microsoft/PowerToys/issues/40501) Fixed tray icon not disappearing when Awake is disabled. The `SetShellIcon` function was incorrectly requiring an icon for Delete operations, causing the `NIM_DELETE` message to never be sent.
|
||||
- [#40659](https://github.com/microsoft/PowerToys/issues/40659) Fixed potential stack overflow crash in EXPIRABLE mode. Added early return after SaveSettings when correcting past expiration times, matching the pattern used by other mode handlers to prevent reentrant execution.
|
||||
|
||||
### `TILLSON_11272024` (November 27, 2024)
|
||||
|
||||
>[!NOTE]
|
||||
>See pull request: [Awake - `TILLSON_11272024`](https://github.com/microsoft/PowerToys/pull/36049)
|
||||
|
||||
- [#35250](https://github.com/microsoft/PowerToys/issues/35250) Updates the icon retry policy, making sure that the icon consistently and correctly renders in the tray.
|
||||
- [#35848](https://github.com/microsoft/PowerToys/issues/35848) Fixed a bug where custom tray time shortcuts for longer than 24 hours would be parsed as zero hours/zero minutes.
|
||||
- [#34716](https://github.com/microsoft/PowerToys/issues/34716) Properly recover the state icon in the tray after an `explorer.exe` crash.
|
||||
- Added configuration safeguards to make sure that invalid values for timed keep-awake times do not result in exceptions.
|
||||
- Updated the tray initialization logic, making sure we wait for it to be properly created before setting icons.
|
||||
- Expanded logging capabilities to track invoking functions.
|
||||
- Added command validation logic to make sure that incorrect command line arguments display an error.
|
||||
- Display state now shown in the tray tooltip.
|
||||
- When timed mode is used, changing the display setting will no longer reset the timer.
|
||||
|
||||
### `PROMETHEAN_09082024` (September 8, 2024)
|
||||
|
||||
>[!NOTE]
|
||||
>See pull request: [Awake - `PROMETHEAN_09082024`](https://github.com/microsoft/PowerToys/pull/34717)
|
||||
|
||||
- Updating the initialization logic to make sure that settings are respected for proper group policy and single-instance detection.
|
||||
- [#34148](https://github.com/microsoft/PowerToys/issues/34148) Fixed a bug from the previous release that incorrectly synchronized threads for shell icon creation and initialized parent PID when it was not parented.
|
||||
|
||||
### `VISEGRADRELAY_08152024` (August 15, 2024)
|
||||
|
||||
>[!NOTE]
|
||||
>See pull request: [Awake - `VISEGRADRELAY_08152024`](https://github.com/microsoft/PowerToys/pull/34316)
|
||||
|
||||
- [#34148](https://github.com/microsoft/PowerToys/issues/34148) Fixes the issue where the Awake icon is not displayed.
|
||||
- [#17969](https://github.com/microsoft/PowerToys/issues/17969) Add the ability to bind the process target to the parent of the Awake launcher.
|
||||
- PID binding now correctly ignores irrelevant parameters (e.g., expiration, interval) and only works for indefinite periods.
|
||||
- Amending the native API surface to make sure that the Win32 error is set correctly.
|
||||
|
||||
### `DAISY023_04102024` (April 10, 2024)
|
||||
|
||||
>[!NOTE]
|
||||
>See pull request: [Awake Update - `DAISY023_04102024`](https://github.com/microsoft/PowerToys/pull/32378)
|
||||
|
||||
- [#33630](https://github.com/microsoft/PowerToys/issues/33630) When in the UI and you select `0` as hours and `0` as minutes in `TIMED` awake mode, the UI becomes non-responsive whenever you try to get back to timed after it rolls back to `PASSIVE`.
|
||||
- [#12714](https://github.com/microsoft/PowerToys/issues/12714) Adds the option to keep track of Awake state through tray tooltip.
|
||||
- [#11996](https://github.com/microsoft/PowerToys/issues/11996) Adds custom icons support for mode changes in Awake.
|
||||
- Removes the dependency on `System.Windows.Forms` and instead uses native Windows APIs to create the tray icon.
|
||||
- Removes redundant/unused code that impacted application performance.
|
||||
- Updates dependent packages to their latest versions (`Microsoft.Windows.CsWinRT` and `System.Reactive`).
|
||||
|
||||
### `ATRIOX_04132023` (April 13, 2023)
|
||||
|
||||
- Moves from using `Task.Run` to spin up threads to actually using a blocking queue that properly sets thread parameters on the same thread.
|
||||
- Moves back to using native Windows APIs through P/Invoke instead of using a package.
|
||||
- Move away from custom logging and to built-in logging that is consistent with the rest of PowerToys.
|
||||
- Updates `System.CommandLine` and `System.Reactive` to the latest preview versions of the package.
|
||||
|
||||
### `LIBRARIAN_03202022` (March 20, 2022)
|
||||
|
||||
- Changed the tray context menu to be following OS conventions instead of the style offered by Windows Forms. This introduces better support for DPI scaling and theming in the future.
|
||||
- Custom times in the tray can now be configured in the `settings.json` file for awake, through the `tray_times` property. The property values are representative of a `Dictionary<string, int>` and can be in the form of `"YOUR_NAME": LENGTH_IN_SECONDS`:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"awake_keep_display_on": true,
|
||||
"awake_mode": 2,
|
||||
"awake_hours": 0,
|
||||
"awake_minutes": 3,
|
||||
"tray_times": {
|
||||
"Custom length": 1800,
|
||||
"Another custom length": 3600
|
||||
}
|
||||
},
|
||||
"name": "Awake",
|
||||
"version": "1.0"
|
||||
}
|
||||
```
|
||||
|
||||
- Proper Awake background window closure was implemented to ensure that the process collects the correct handle instead of the empty one that was previously done through `System.Diagnostics.Process.GetCurrentProcess().CloseMainWindow()`. This likely can help with the Awake process that is left hanging after PowerToys itself closes.
|
||||
@@ -195,10 +195,18 @@ Special sections start with an identifier enclosed between `<` and `>`. This dec
|
||||
|
||||
A string array of all the keys that need to be pressed. If a number is supplied, it should be read as a [KeyCode](https://learn.microsoft.com/windows/win32/inputdev/virtual-key-codes) and displayed accordingly (based on the Keyboard Layout of the user).
|
||||
|
||||
**Literal digit keys**:
|
||||
|
||||
Because a bare number is interpreted as a virtual-key code, a literal digit key must be authored using the `<N>` notation (the digit enclosed between `<` and `>`), where `N` is `0`–`9`. For example, `<9>` represents the literal `9` key (as in the "switch to the last tab" shortcut), not the virtual-key code `9` (which is `Tab`). The interpreter strips the brackets and displays just the digit.
|
||||
|
||||
This applies only to a single literal digit. A range such as `1 - 8` is a free-form label, not a key, and is supplied verbatim (the brackets would only be trimmed from the ends, so `<1> - <8>` would not render as intended).
|
||||
|
||||
**Special keys**:
|
||||
|
||||
Special keys are enclosed between `<` and `>` and correspond to a key that should be displayed in a certain way. If the interpreter of the manifest file can't understand the content, the brackets should be left out.
|
||||
|
||||
By convention these tokens are written as double-quoted strings in the YAML (for example `"<Enter>"` and `"<9>"`), matching the quoting used for punctuation key values. YAML treats the quoted and unquoted forms identically, so quoting is for consistency rather than a strict requirement for bracketed tokens.
|
||||
|
||||
|Name|Description|
|
||||
|----|-----------|
|
||||
|`<Office>`| Corresponds to the Office key on some Windows keyboards |
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Unofficial community driven install methods
|
||||
|
||||
These are community driven alternative install methods to Windows Package Manager (WinGet) and GitHub. The PowerToys teams does not update or manage these install methods.
|
||||
|
||||
These will be listed in alphabetical order.
|
||||
|
||||
## Chocolatey
|
||||
|
||||
Download and upgrade PowerToys from [Chocolatey](https://chocolatey.org). If you have any issues when installing/upgrading the package please go to the [package page](https://chocolatey.org/packages/powertoys) and follow the [Chocolatey triage process](https://chocolatey.org/docs/package-triage-process)
|
||||
|
||||
To install PowerToys, run the following command from the command line / PowerShell:
|
||||
|
||||
```powershell
|
||||
choco install powertoys
|
||||
```
|
||||
|
||||
To upgrade PowerToys, run the following command from the command line / PowerShell:
|
||||
|
||||
```powershell
|
||||
choco upgrade powertoys
|
||||
```
|
||||
|
||||
## Scoop
|
||||
|
||||
Download and update PowerToys from [Scoop](https://scoop.sh).
|
||||
|
||||
To install PowerToys, run the following command from the command line / PowerShell:
|
||||
|
||||
```powershell
|
||||
scoop install powertoys
|
||||
```
|
||||
|
||||
To update PowerToys, run the following command from the command line / PowerShell:
|
||||
|
||||
```powershell
|
||||
scoop update powertoys
|
||||
```
|
||||
@@ -95,6 +95,9 @@ std::optional<fs::path> CopySelfToTempDir()
|
||||
return dst_path;
|
||||
}
|
||||
|
||||
// The installer filename read from UpdateState.json is validated by
|
||||
// updating::IsSafeDownloadedInstallerFilename (common/updating/updateLifecycle.h)
|
||||
// so it can be unit-tested. See ObtainInstaller below for how it's used.
|
||||
std::optional<fs::path> ObtainInstaller(bool& isUpToDate)
|
||||
{
|
||||
using namespace updating;
|
||||
@@ -107,7 +110,25 @@ std::optional<fs::path> ObtainInstaller(bool& isUpToDate)
|
||||
// so we don't need a GitHub API call (which may fail if offline).
|
||||
if (state.state == UpdateState::readyToInstall)
|
||||
{
|
||||
fs::path installer{ get_pending_updates_path() / state.downloadedInstallerFilename };
|
||||
if (!IsSafeDownloadedInstallerFilename(state.downloadedInstallerFilename))
|
||||
{
|
||||
Logger::error(L"Ignoring unexpected downloadedInstallerFilename from update state: {}", state.downloadedInstallerFilename);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const fs::path updatesDir = get_pending_updates_path();
|
||||
fs::path installer{ updatesDir / state.downloadedInstallerFilename };
|
||||
|
||||
// Make sure the resolved path actually stays within the Updates directory.
|
||||
std::error_code ec;
|
||||
const fs::path normalizedInstaller = fs::weakly_canonical(installer, ec);
|
||||
const fs::path normalizedUpdatesDir = fs::weakly_canonical(updatesDir, ec);
|
||||
if (ec || normalizedInstaller.parent_path() != normalizedUpdatesDir)
|
||||
{
|
||||
Logger::error(L"Resolved installer path is outside the updates directory: {}", installer.native());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
if (fs::is_regular_file(installer))
|
||||
{
|
||||
return std::move(installer);
|
||||
|
||||
@@ -73,8 +73,7 @@
|
||||
<PrecompiledHeaderOutputFile>$(IntDir)pch.pch</PrecompiledHeaderOutputFile>
|
||||
<WarningLevel>Level4</WarningLevel>
|
||||
<AdditionalOptions>%(AdditionalOptions) /bigobj</AdditionalOptions>
|
||||
<!-- TODO: _SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS: suppress VS 2026 STL hard error for <experimental/coroutine> until the code is ported to <coroutine> -->
|
||||
<PreprocessorDefinitions>_WINRT_DLL;WINRT_LEAN_AND_MEAN;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<PreprocessorDefinitions>_WINRT_DLL;WINRT_LEAN_AND_MEAN;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<AdditionalIncludeDirectories>../../..;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
<AdditionalUsingDirectories>$(WindowsSDK_WindowsMetadata);$(AdditionalUsingDirectories)</AdditionalUsingDirectories>
|
||||
</ClCompile>
|
||||
@@ -162,7 +161,7 @@
|
||||
<ClCompile>
|
||||
<!-- We use MultiThreadedDebug, rather than MultiThreadedDebugDLL, to avoid DLL dependencies on VCRUNTIME140d.dll and MSVCP140d.dll. -->
|
||||
<RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
|
||||
<LanguageStandard Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">stdcpp17</LanguageStandard>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<!-- Link statically against the runtime and STL, but link dynamically against the CRT by ignoring the static CRT
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace ManagedCommon
|
||||
Workspaces,
|
||||
GrabAndMove,
|
||||
ZoomIt,
|
||||
PowerScripts,
|
||||
GeneralSettings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ namespace Microsoft.Interop.Tests
|
||||
ClientPipe.Start();
|
||||
|
||||
// Test can be flaky as the pipes are still being set up and we end up receiving no message. Wait for a bit to avoid that.
|
||||
Thread.Sleep(100);
|
||||
Thread.Sleep(500);
|
||||
|
||||
ClientPipe.Send(testString);
|
||||
|
||||
|
||||
@@ -676,4 +676,61 @@ namespace UpdatingUnitTests
|
||||
LocalFree(argv);
|
||||
}
|
||||
};
|
||||
|
||||
// Tests for IsSafeDownloadedInstallerFilename: the updater reads
|
||||
// downloadedInstallerFilename from the persisted UpdateState.json and must only
|
||||
// accept a plain filename so it never looks outside the Updates folder when the
|
||||
// cached state is stale, corrupted, or otherwise unexpected.
|
||||
TEST_CLASS(IsSafeDownloadedInstallerFilenameTests)
|
||||
{
|
||||
public:
|
||||
// Normal installer asset names are bare filenames and must be accepted,
|
||||
// so the regular update flow does not regress.
|
||||
TEST_METHOD(NormalInstallerFilenamesAreAccepted)
|
||||
{
|
||||
Assert::IsTrue(updating::IsSafeDownloadedInstallerFilename(L"PowerToysSetup-0.95.0-x64.exe"));
|
||||
Assert::IsTrue(updating::IsSafeDownloadedInstallerFilename(L"PowerToysUserSetup-0.95.0-arm64.exe"));
|
||||
Assert::IsTrue(updating::IsSafeDownloadedInstallerFilename(L"PowerToysSetup-1.0.0-x64.msi"));
|
||||
}
|
||||
|
||||
// Empty values must be rejected (no installer to run).
|
||||
TEST_METHOD(EmptyFilenameIsRejected)
|
||||
{
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L""));
|
||||
}
|
||||
|
||||
// Relative parent-directory components must be rejected.
|
||||
TEST_METHOD(ParentDirectoryComponentsAreRejected)
|
||||
{
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"..\\..\\setup.msi"));
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"../../setup.exe"));
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L".."));
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"."));
|
||||
}
|
||||
|
||||
// Any directory component (even without "..") must be rejected — the value
|
||||
// must be a single bare filename.
|
||||
TEST_METHOD(NestedPathComponentsAreRejected)
|
||||
{
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"sub\\setup.exe"));
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"sub/setup.exe"));
|
||||
}
|
||||
|
||||
// Absolute paths, drive-relative paths and UNC paths must be rejected,
|
||||
// because fs::path's operator/ would let them replace the Updates directory.
|
||||
TEST_METHOD(AbsoluteAndUncPathsAreRejected)
|
||||
{
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"C:\\setup.msi"));
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"C:setup.msi"));
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"\\setup.msi"));
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"\\\\server\\share\\setup.exe"));
|
||||
}
|
||||
|
||||
// A bare filename that merely contains a ".." substring is rejected as a
|
||||
// conservative measure (real asset names never contain "..").
|
||||
TEST_METHOD(EmbeddedDotDotSubstringIsRejected)
|
||||
{
|
||||
Assert::IsFalse(updating::IsSafeDownloadedInstallerFilename(L"setup..name.exe"));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -44,4 +44,43 @@ namespace updating
|
||||
// args[0]=exe, args[1]=action, args[2]=installer, args[3]=installDir
|
||||
return argCount >= 4;
|
||||
}
|
||||
|
||||
// Returns true when the value read from UpdateState.json is a plain installer
|
||||
// filename that can be combined with the pending-updates directory. UpdateState.json
|
||||
// is persisted state that may be stale, corrupted, or otherwise unexpected, so the
|
||||
// cached filename could contain path separators or an absolute/drive-relative path.
|
||||
// Only a single bare filename (the form produced by the download step) is accepted;
|
||||
// anything else is rejected so the updater never looks outside the Updates folder.
|
||||
inline bool IsSafeDownloadedInstallerFilename(const std::wstring& filename)
|
||||
{
|
||||
if (filename.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject any path separators or parent-directory tokens outright. Installer
|
||||
// asset filenames never contain these.
|
||||
if (filename.find(L'/') != std::wstring::npos ||
|
||||
filename.find(L'\\') != std::wstring::npos ||
|
||||
filename.find(L"..") != std::wstring::npos)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const fs::path candidate{ filename };
|
||||
|
||||
// Must be a single path component: no drive/root and no directory portion.
|
||||
if (candidate.has_root_name() || candidate.has_root_directory() || candidate.has_parent_path())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto name = candidate.filename().wstring();
|
||||
if (name != filename || name == L"." || name == L"..")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
|
||||
public bool ShowCustomPreview => false;
|
||||
|
||||
public bool ShowAIPaste => true;
|
||||
|
||||
public bool CloseAfterLosingFocus => false;
|
||||
|
||||
public bool EnableClipboardPreview => true;
|
||||
|
||||
@@ -251,7 +251,8 @@
|
||||
Margin="20,0,20,0"
|
||||
x:FieldModifier="public"
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay}"
|
||||
TabIndex="0">
|
||||
TabIndex="0"
|
||||
Visibility="{x:Bind ViewModel.ShowAIPasteSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
|
||||
<controls:PromptBox.Footer>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock
|
||||
|
||||
@@ -17,6 +17,8 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public bool ShowCustomPreview { get; }
|
||||
|
||||
public bool ShowAIPaste { get; }
|
||||
|
||||
public bool CloseAfterLosingFocus { get; }
|
||||
|
||||
public bool EnableClipboardPreview { get; }
|
||||
|
||||
@@ -38,6 +38,8 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public bool ShowCustomPreview { get; private set; }
|
||||
|
||||
public bool ShowAIPaste { get; private set; }
|
||||
|
||||
public bool CloseAfterLosingFocus { get; private set; }
|
||||
|
||||
public bool EnableClipboardPreview { get; private set; }
|
||||
@@ -54,6 +56,7 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
IsAIEnabled = false;
|
||||
ShowCustomPreview = true;
|
||||
ShowAIPaste = true;
|
||||
CloseAfterLosingFocus = false;
|
||||
EnableClipboardPreview = true;
|
||||
PasteAIConfiguration = new PasteAIConfiguration();
|
||||
@@ -109,6 +112,7 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
IsAIEnabled = properties.IsAIEnabled;
|
||||
ShowCustomPreview = properties.ShowCustomPreview;
|
||||
ShowAIPaste = properties.ShowAIPaste;
|
||||
CloseAfterLosingFocus = properties.CloseAfterLosingFocus;
|
||||
EnableClipboardPreview = properties.EnableClipboardPreview;
|
||||
PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration();
|
||||
|
||||
@@ -234,6 +234,8 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public bool ShowClipboardHistoryButton => ClipboardHistoryEnabled;
|
||||
|
||||
public bool ShowAIPasteSection => _userSettings.ShowAIPaste && IsAllowedByGPO;
|
||||
|
||||
public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress);
|
||||
|
||||
private PasteFormats CustomAIFormat =>
|
||||
@@ -320,6 +322,7 @@ namespace AdvancedPaste.ViewModels
|
||||
OnPropertyChanged(nameof(AIProviders));
|
||||
OnPropertyChanged(nameof(AllowedAIProviders));
|
||||
OnPropertyChanged(nameof(ShowClipboardPreview));
|
||||
OnPropertyChanged(nameof(ShowAIPasteSection));
|
||||
|
||||
NotifyActiveProviderChanged();
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"}
|
||||
{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"ShowAIPaste":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"}
|
||||
@@ -144,17 +144,19 @@ namespace EnvironmentVariablesUILib.Helpers
|
||||
set.Variables = new System.Collections.ObjectModel.ObservableCollection<Variable>(sortedList.Values);
|
||||
}
|
||||
|
||||
internal static bool SetVariableWithoutNotify(Variable variable)
|
||||
// Profiles override User variables only (see doc/devdocs/modules/environmentvariables.md), so
|
||||
// every registry write driven by a profile targets the current-user environment regardless of a
|
||||
// variable's ParentType. These helpers centralize that behavior for the apply/unapply/edit paths.
|
||||
internal static bool SetProfileVariableWithoutNotify(Variable variable)
|
||||
{
|
||||
bool fromMachine = variable.ParentType switch
|
||||
{
|
||||
VariablesSetType.Profile => false,
|
||||
VariablesSetType.User => false,
|
||||
VariablesSetType.System => true,
|
||||
_ => throw new NotImplementedException(),
|
||||
};
|
||||
SetEnvironmentVariableFromRegistryWithoutNotify(variable.Name, variable.Values, fromMachine: false);
|
||||
|
||||
SetEnvironmentVariableFromRegistryWithoutNotify(variable.Name, variable.Values, fromMachine);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool UnsetProfileVariableWithoutNotify(Variable variable)
|
||||
{
|
||||
SetEnvironmentVariableFromRegistryWithoutNotify(variable.Name, null, fromMachine: false);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -175,21 +177,6 @@ namespace EnvironmentVariablesUILib.Helpers
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool UnsetVariableWithoutNotify(Variable variable)
|
||||
{
|
||||
bool fromMachine = variable.ParentType switch
|
||||
{
|
||||
VariablesSetType.Profile => false,
|
||||
VariablesSetType.User => false,
|
||||
VariablesSetType.System => true,
|
||||
_ => throw new NotImplementedException(),
|
||||
};
|
||||
|
||||
SetEnvironmentVariableFromRegistryWithoutNotify(variable.Name, null, fromMachine);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool UnsetVariable(Variable variable)
|
||||
{
|
||||
bool fromMachine = variable.ParentType switch
|
||||
|
||||
@@ -35,8 +35,6 @@ namespace EnvironmentVariablesUILib.Models
|
||||
{
|
||||
foreach (var variable in Variables)
|
||||
{
|
||||
var applyToSystem = variable.ApplyToSystem;
|
||||
|
||||
// Get existing variable with the same name if it exist
|
||||
var variableToOverride = EnvironmentVariablesHelper.GetExisting(variable.Name);
|
||||
|
||||
@@ -46,13 +44,13 @@ namespace EnvironmentVariablesUILib.Models
|
||||
variableToOverride.Name = EnvironmentVariablesHelper.GetBackupVariableName(variableToOverride, this.Name);
|
||||
|
||||
// Backup the variable
|
||||
if (!EnvironmentVariablesHelper.SetVariableWithoutNotify(variableToOverride))
|
||||
if (!EnvironmentVariablesHelper.SetProfileVariableWithoutNotify(variableToOverride))
|
||||
{
|
||||
LoggerInstance.Logger.LogError("Failed to set backup variable.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!EnvironmentVariablesHelper.SetVariableWithoutNotify(variable))
|
||||
if (!EnvironmentVariablesHelper.SetProfileVariableWithoutNotify(variable))
|
||||
{
|
||||
LoggerInstance.Logger.LogError("Failed to set profile variable.");
|
||||
}
|
||||
@@ -78,7 +76,7 @@ namespace EnvironmentVariablesUILib.Models
|
||||
public void UnapplyVariable(Variable variable)
|
||||
{
|
||||
// Unset the variable
|
||||
if (!EnvironmentVariablesHelper.UnsetVariableWithoutNotify(variable))
|
||||
if (!EnvironmentVariablesHelper.UnsetProfileVariableWithoutNotify(variable))
|
||||
{
|
||||
LoggerInstance.Logger.LogError("Failed to unset variable.");
|
||||
}
|
||||
@@ -93,12 +91,12 @@ namespace EnvironmentVariablesUILib.Models
|
||||
{
|
||||
var variableToRestore = new Variable(originalName, backupVariable.Values, backupVariable.ParentType);
|
||||
|
||||
if (!EnvironmentVariablesHelper.UnsetVariableWithoutNotify(backupVariable))
|
||||
if (!EnvironmentVariablesHelper.UnsetProfileVariableWithoutNotify(backupVariable))
|
||||
{
|
||||
LoggerInstance.Logger.LogError("Failed to unset backup variable.");
|
||||
}
|
||||
|
||||
if (!EnvironmentVariablesHelper.SetVariableWithoutNotify(variableToRestore))
|
||||
if (!EnvironmentVariablesHelper.SetProfileVariableWithoutNotify(variableToRestore))
|
||||
{
|
||||
LoggerInstance.Logger.LogError("Failed to restore backup variable.");
|
||||
}
|
||||
|
||||
@@ -143,12 +143,12 @@ namespace EnvironmentVariablesUILib.Models
|
||||
{
|
||||
var variableToRestore = new Variable(clone.Name, backupVariable.Values, backupVariable.ParentType);
|
||||
|
||||
if (!EnvironmentVariablesHelper.UnsetVariableWithoutNotify(backupVariable))
|
||||
if (!EnvironmentVariablesHelper.UnsetProfileVariableWithoutNotify(backupVariable))
|
||||
{
|
||||
LoggerInstance.Logger.LogError("Failed to unset backup variable.");
|
||||
}
|
||||
|
||||
if (!EnvironmentVariablesHelper.SetVariableWithoutNotify(variableToRestore))
|
||||
if (!EnvironmentVariablesHelper.SetProfileVariableWithoutNotify(variableToRestore))
|
||||
{
|
||||
LoggerInstance.Logger.LogError("Failed to restore backup variable.");
|
||||
}
|
||||
@@ -169,7 +169,7 @@ namespace EnvironmentVariablesUILib.Models
|
||||
if (EnvironmentVariablesHelper.GetExisting(variableToOverride.Name) == null)
|
||||
{
|
||||
// Backup the variable
|
||||
if (!EnvironmentVariablesHelper.SetVariableWithoutNotify(variableToOverride))
|
||||
if (!EnvironmentVariablesHelper.SetProfileVariableWithoutNotify(variableToOverride))
|
||||
{
|
||||
LoggerInstance.Logger.LogError("Failed to set backup variable.");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
|
||||
@@ -20,7 +21,20 @@ namespace PowerToys.FileLocksmithUI.Converters
|
||||
|
||||
if (!string.IsNullOrEmpty(y))
|
||||
{
|
||||
icon = Icon.ExtractAssociatedIcon(y);
|
||||
try
|
||||
{
|
||||
icon = Icon.ExtractAssociatedIcon(y);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The process image path can be non-empty but no longer exist on disk
|
||||
// (e.g. self-updating software that deletes its old versioned directory while
|
||||
// the old process is still running). ExtractAssociatedIcon then throws and,
|
||||
// because this converter runs per-row during ListView virtualization, the
|
||||
// exception would otherwise reach App_UnhandledException and fast-fail the app.
|
||||
// Fall through to the placeholder icon instead of crashing.
|
||||
Logger.LogWarning($"Couldn't extract the icon for '{y}'. {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
if (icon != null)
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<AdditionalDependencies>WindowsApp.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
<AdditionalDependencies>WindowsApp.lib;Gdiplus.lib;Dwmapi.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
#include "resource.h"
|
||||
|
||||
#include <dwmapi.h>
|
||||
|
||||
TRACELOGGING_DEFINE_PROVIDER(
|
||||
g_hProvider,
|
||||
"Microsoft.PowerToys",
|
||||
@@ -21,6 +23,7 @@ TRACELOGGING_DEFINE_PROVIDER(
|
||||
// Globals
|
||||
// ---------------------------------------------------------------------------
|
||||
static HINSTANCE g_hInstance = nullptr;
|
||||
static ULONG_PTR g_gdiplusToken = 0; // GDI+ token for overlay border rendering
|
||||
static HHOOK g_hhkKeyboard = nullptr;
|
||||
static HHOOK g_hhkMouse = nullptr;
|
||||
static HWND g_hMsgWnd = nullptr;
|
||||
@@ -47,6 +50,29 @@ static HWND g_hOverlay = nullptr; // semi-transparent overlay during drag
|
||||
static int g_overlayInfoX = 0, g_overlayInfoY = 0;
|
||||
static int g_overlayInfoW = 0, g_overlayInfoH = 0;
|
||||
|
||||
// Visible frame overlay metrics. Computed once per drag/resize (cold path) and
|
||||
// reused while rendering - never recomputed in the mouse-move hot path.
|
||||
// Margins are the difference between GetWindowRect and the DWM extended frame
|
||||
// bounds (the invisible resize border), so the fill and border hug the visible
|
||||
// window. The border is drawn just inside the visible edge; Always On Top draws
|
||||
// its own border just outside that edge, so the two stack into a clean double
|
||||
// layer without Grab and Move having to widen its stroke.
|
||||
static int g_overlayMarginL = 0, g_overlayMarginT = 0, g_overlayMarginR = 0, g_overlayMarginB = 0;
|
||||
static int g_overlayCornerRadius = 0; // physical px; 0 = square corners
|
||||
static int g_overlayBorderThickness = 4; // physical px
|
||||
|
||||
// Fluent "warning" gold - copy of WinUI SystemFillColorCaution
|
||||
// (used as a ThemeResource for warnings across the Settings UI). A Win32 layered
|
||||
// window can't resolve a ThemeResource, so the literal is required here.
|
||||
static constexpr COLORREF OVERLAY_BORDER_COLOR = RGB(255, 185, 0); // #FFB900
|
||||
|
||||
// Border thickness in DIPs (scaled by the target window DPI).
|
||||
static constexpr int OVERLAY_BORDER_DIP = 4;
|
||||
|
||||
// Translucent white wash painted over the visible window during a drag/resize,
|
||||
// matching the prior overlay. ~40% opacity (premultiplied white = 0x66666666).
|
||||
static constexpr BYTE OVERLAY_FILL_ALPHA = 0x66;
|
||||
|
||||
static bool g_shouldAbsorbAlt =
|
||||
true; // true if we want to absorb Alt on the next keydown (set when Alt is pressed without dragging, cleared on next non-Alt key or Alt keyup)
|
||||
static bool g_altAbsorbed = false; // true if we absorbed an Alt keydown
|
||||
@@ -343,9 +369,121 @@ static void SettingsWatcherThread(DWORD mainThreadId)
|
||||
static int g_overlayRenderedW = 0;
|
||||
static int g_overlayRenderedH = 0;
|
||||
|
||||
// Maps the DWM window corner preference to a base radius in DIPs, matching
|
||||
// Always On Top (WindowCornerUtils::CornersRadius).
|
||||
static int CornerRadiusForWindow(HWND hwnd)
|
||||
{
|
||||
int pref = 0; // DWMWCP_DEFAULT
|
||||
if (DwmGetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)) != S_OK)
|
||||
{
|
||||
return 0; // pre-Win11 / unsupported -> square corners
|
||||
}
|
||||
|
||||
switch (pref)
|
||||
{
|
||||
case DWMWCP_ROUND:
|
||||
return 8;
|
||||
case DWMWCP_ROUNDSMALL:
|
||||
return 4;
|
||||
case DWMWCP_DEFAULT:
|
||||
return 8;
|
||||
default:
|
||||
return 0; // DWMWCP_DONOTROUND
|
||||
}
|
||||
}
|
||||
|
||||
// Computes the overlay metrics (margins to the visible frame, corner radius, border
|
||||
// thickness) for the target window. Cold path only: called at the start of a
|
||||
// drag/resize and after un-maximize, never from the mouse-move hot path.
|
||||
static void PrepareOverlayMetrics(HWND target)
|
||||
{
|
||||
g_overlayMarginL = g_overlayMarginT = g_overlayMarginR = g_overlayMarginB = 0;
|
||||
g_overlayCornerRadius = 0;
|
||||
g_overlayBorderThickness = OVERLAY_BORDER_DIP;
|
||||
|
||||
if (!target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const UINT dpi = GetDpiForWindow(target);
|
||||
const float scale = (dpi != 0) ? dpi / 96.0f : 1.0f;
|
||||
|
||||
RECT windowRect{};
|
||||
RECT frameRect{};
|
||||
if (GetWindowRect(target, &windowRect) &&
|
||||
SUCCEEDED(DwmGetWindowAttribute(target, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRect, sizeof(frameRect))))
|
||||
{
|
||||
g_overlayMarginL = max(0, static_cast<int>(frameRect.left - windowRect.left));
|
||||
g_overlayMarginT = max(0, static_cast<int>(frameRect.top - windowRect.top));
|
||||
g_overlayMarginR = max(0, static_cast<int>(windowRect.right - frameRect.right));
|
||||
g_overlayMarginB = max(0, static_cast<int>(windowRect.bottom - frameRect.bottom));
|
||||
}
|
||||
|
||||
g_overlayCornerRadius = static_cast<int>(CornerRadiusForWindow(target) * scale);
|
||||
g_overlayBorderThickness = static_cast<int>(OVERLAY_BORDER_DIP * scale);
|
||||
}
|
||||
|
||||
// Draws an antialiased (optionally rounded) border stroke fully inside `rect` using
|
||||
// GDI+. The stroke hugs the inner edge of `rect` (the visible window frame).
|
||||
static void DrawOverlayBorder(Gdiplus::Graphics& graphics, const RECT& rect, int thickness, int radius)
|
||||
{
|
||||
const int w = rect.right - rect.left;
|
||||
const int h = rect.bottom - rect.top;
|
||||
if (w <= 0 || h <= 0 || thickness <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the whole stroke inside the visible frame on every side.
|
||||
thickness = min(thickness, min(w, h) / 2);
|
||||
if (thickness <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const float half = thickness / 2.0f;
|
||||
const Gdiplus::RectF path(
|
||||
rect.left + half,
|
||||
rect.top + half,
|
||||
static_cast<Gdiplus::REAL>(w) - thickness,
|
||||
static_cast<Gdiplus::REAL>(h) - thickness);
|
||||
|
||||
graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||||
Gdiplus::Pen pen(
|
||||
Gdiplus::Color(255, GetRValue(OVERLAY_BORDER_COLOR), GetGValue(OVERLAY_BORDER_COLOR), GetBValue(OVERLAY_BORDER_COLOR)),
|
||||
static_cast<Gdiplus::REAL>(thickness));
|
||||
|
||||
if (radius <= 0)
|
||||
{
|
||||
graphics.DrawRectangle(&pen, path);
|
||||
return;
|
||||
}
|
||||
|
||||
// The stroke is centred, so the path corner radius is the window radius minus
|
||||
// half the thickness; that keeps the outer edge aligned with the window corner.
|
||||
const float pathRadius = max(0.0f, radius - half);
|
||||
const float diameter = min(pathRadius * 2.0f, min(path.Width, path.Height));
|
||||
if (diameter <= 0.0f)
|
||||
{
|
||||
graphics.DrawRectangle(&pen, path);
|
||||
return;
|
||||
}
|
||||
|
||||
Gdiplus::GraphicsPath border;
|
||||
border.AddArc(path.X, path.Y, diameter, diameter, 180.0f, 90.0f);
|
||||
border.AddArc(path.GetRight() - diameter, path.Y, diameter, diameter, 270.0f, 90.0f);
|
||||
border.AddArc(path.GetRight() - diameter, path.GetBottom() - diameter, diameter, diameter, 0.0f, 90.0f);
|
||||
border.AddArc(path.X, path.GetBottom() - diameter, diameter, diameter, 90.0f, 90.0f);
|
||||
border.CloseFigure();
|
||||
graphics.DrawPath(&pen, &border);
|
||||
}
|
||||
|
||||
// Renders the overlay surface using per-pixel alpha via UpdateLayeredWindow.
|
||||
// The white background is painted at ~40% opacity; the geometry label box is
|
||||
// painted fully opaque so it remains legible regardless of what is beneath.
|
||||
// A translucent white wash covers the visible window (matching the prior overlay)
|
||||
// with a tight warning-gold border on top, both hugging the visible window frame;
|
||||
// the optional geometry label box is painted fully opaque so it remains legible
|
||||
// regardless of what is beneath.
|
||||
static void RenderOverlayContent(HWND hwnd, int cw, int ch)
|
||||
{
|
||||
if (!hwnd || cw <= 0 || ch <= 0)
|
||||
@@ -372,8 +510,52 @@ static void RenderOverlayContent(HWND hwnd, int cw, int ch)
|
||||
HDC memDC = CreateCompatibleDC(screenDC);
|
||||
HBITMAP hOldBmp = static_cast<HBITMAP>(SelectObject(memDC, hDib));
|
||||
|
||||
// Premultiplied white at ~40% opacity: A=0x66, R=G=B=0x66 → 0x66666666
|
||||
memset(pBits, 0x66, static_cast<size_t>(cw) * ch * sizeof(DWORD));
|
||||
// Start fully transparent.
|
||||
memset(pBits, 0, static_cast<size_t>(cw) * ch * sizeof(DWORD));
|
||||
|
||||
// We apply a translucent white rect with a gold border.
|
||||
// The overlay window spans GetWindowRect, so inset by
|
||||
// the invisible-border margins so both hug the visible edge; Always On Top draws
|
||||
// its own border just outside that edge, giving a clean double layer.
|
||||
{
|
||||
const RECT visible = {
|
||||
g_overlayMarginL,
|
||||
g_overlayMarginT,
|
||||
cw - g_overlayMarginR,
|
||||
ch - g_overlayMarginB
|
||||
};
|
||||
const int vw = visible.right - visible.left;
|
||||
const int vh = visible.bottom - visible.top;
|
||||
|
||||
Gdiplus::Bitmap bitmap(cw, ch, cw * 4, PixelFormat32bppPARGB, reinterpret_cast<BYTE*>(pBits));
|
||||
Gdiplus::Graphics graphics(&bitmap);
|
||||
graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||||
|
||||
if (vw > 0 && vh > 0)
|
||||
{
|
||||
Gdiplus::SolidBrush fillBrush(Gdiplus::Color(OVERLAY_FILL_ALPHA, 255, 255, 255));
|
||||
if (g_overlayCornerRadius > 0)
|
||||
{
|
||||
// Round the wash to match the window corners (and the border).
|
||||
const float d = min(static_cast<float>(g_overlayCornerRadius) * 2.0f,
|
||||
static_cast<float>(min(vw, vh)));
|
||||
Gdiplus::GraphicsPath fillPath;
|
||||
fillPath.AddArc(static_cast<float>(visible.left), static_cast<float>(visible.top), d, d, 180.0f, 90.0f);
|
||||
fillPath.AddArc(static_cast<float>(visible.right) - d, static_cast<float>(visible.top), d, d, 270.0f, 90.0f);
|
||||
fillPath.AddArc(static_cast<float>(visible.right) - d, static_cast<float>(visible.bottom) - d, d, d, 0.0f, 90.0f);
|
||||
fillPath.AddArc(static_cast<float>(visible.left), static_cast<float>(visible.bottom) - d, d, d, 90.0f, 90.0f);
|
||||
fillPath.CloseFigure();
|
||||
graphics.FillPath(&fillBrush, &fillPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
graphics.FillRectangle(&fillBrush, visible.left, visible.top, vw, vh);
|
||||
}
|
||||
}
|
||||
|
||||
DrawOverlayBorder(graphics, visible, g_overlayBorderThickness, g_overlayCornerRadius);
|
||||
graphics.Flush();
|
||||
}
|
||||
|
||||
if (g_showGeometry)
|
||||
{
|
||||
@@ -1045,6 +1227,7 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
g_dragTarget = hwnd;
|
||||
g_dragStart = pt;
|
||||
GetWindowRect(hwnd, &g_dragWndRect);
|
||||
PrepareOverlayMetrics(hwnd);
|
||||
|
||||
// Show the semi-transparent overlay on top of the target (persistent window – fix #9)
|
||||
ShowOverlay(g_dragWndRect, g_curSizeAll);
|
||||
@@ -1112,6 +1295,7 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
g_resizeTarget = hwnd;
|
||||
g_resizeLast = pt;
|
||||
GetWindowRect(hwnd, &g_resizeWndRect);
|
||||
PrepareOverlayMetrics(hwnd);
|
||||
|
||||
g_currentHandle = GetClosestHandle(pt, g_resizeWndRect);
|
||||
ShowOverlay(g_resizeWndRect, CursorForHandle(g_currentHandle));
|
||||
@@ -1183,6 +1367,9 @@ static void HandleDragMove(POINT pt)
|
||||
|
||||
g_dragStart = pt;
|
||||
g_dragWndRect = {newX, newY, newX + restoredW, newY + restoredH};
|
||||
|
||||
// Corner radius / invisible-border margins differ once restored.
|
||||
PrepareOverlayMetrics(g_dragTarget);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1230,6 +1417,9 @@ static void HandleDragResize(POINT pt)
|
||||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
|
||||
g_resizeWndRect = {newLeft, newTop, newLeft + newW, newTop + newH};
|
||||
|
||||
// Corner radius / invisible-border margins differ once restored.
|
||||
PrepareOverlayMetrics(g_resizeTarget);
|
||||
|
||||
g_resizeLast = pt;
|
||||
g_currentHandle = GetClosestHandle(pt, g_resizeWndRect);
|
||||
}
|
||||
@@ -1375,6 +1565,10 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR lpCmdLine, int)
|
||||
INITCOMMONCONTROLSEX commonControls = { sizeof(commonControls), ICC_STANDARD_CLASSES };
|
||||
InitCommonControlsEx(&commonControls);
|
||||
|
||||
// Initialise GDI+ for antialiased overlay border rendering
|
||||
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
|
||||
Gdiplus::GdiplusStartup(&g_gdiplusToken, &gdiplusStartupInput, nullptr);
|
||||
|
||||
// Register a message-only window class
|
||||
WNDCLASSEXW wc = {};
|
||||
wc.cbSize = sizeof(wc);
|
||||
@@ -1387,13 +1581,13 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR lpCmdLine, int)
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Register the overlay window class (white background, ARROW cursor)
|
||||
// Register the overlay window class (layered per-pixel-alpha surface, ARROW cursor)
|
||||
WNDCLASSEXW overlayWindowClass = {};
|
||||
overlayWindowClass.cbSize = sizeof(overlayWindowClass);
|
||||
overlayWindowClass.lpfnWndProc = DefWindowProcW;
|
||||
overlayWindowClass.hInstance = hInstance;
|
||||
overlayWindowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
overlayWindowClass.hbrBackground = static_cast<HBRUSH>(GetStockObject(WHITE_BRUSH));
|
||||
overlayWindowClass.hbrBackground = nullptr; // per-pixel alpha via UpdateLayeredWindow
|
||||
overlayWindowClass.lpszClassName = OVERLAY_CLASS_NAME;
|
||||
if (!RegisterClassExW(&overlayWindowClass))
|
||||
{
|
||||
@@ -1482,6 +1676,11 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR lpCmdLine, int)
|
||||
g_hOverlay = nullptr;
|
||||
}
|
||||
RemoveTrayIcon();
|
||||
if (g_gdiplusToken)
|
||||
{
|
||||
Gdiplus::GdiplusShutdown(g_gdiplusToken);
|
||||
g_gdiplusToken = 0;
|
||||
}
|
||||
TraceLoggingUnregister(g_hProvider);
|
||||
|
||||
return static_cast<int>(msg.wParam);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
#include <shellapi.h>
|
||||
#include <commctrl.h>
|
||||
#include <TraceLoggingProvider.h>
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
// <summary>
|
||||
// Encrypt/decrypt implementation.
|
||||
@@ -32,13 +31,17 @@ internal static class Encryption
|
||||
private static Random ran = new(); // Used for non encryption related functionality.
|
||||
internal const int SymAlBlockSize = 16;
|
||||
|
||||
/// <summary>
|
||||
/// This is used for the first encryption block, the following blocks will be combined with the cipher text of the previous block.
|
||||
/// Thus identical blocks in the socket stream would be encrypted to different cipher text blocks.
|
||||
/// The first block is a handshake one containing random data.
|
||||
/// Related Unit Test: TestEncryptDecrypt
|
||||
/// </summary>
|
||||
private static readonly string InitialIV = ulong.MaxValue.ToString(CultureInfo.InvariantCulture);
|
||||
// Size (in bytes) of the random, per-connection PBKDF2 salt that is exchanged in the
|
||||
// clear at the start of every encrypted stream. A unique random salt prevents an
|
||||
// attacker from pre-computing a single brute-force/rainbow table that could be reused
|
||||
// against every captured connection.
|
||||
private const int SaltSize = 16;
|
||||
|
||||
// Number of PBKDF2 iterations used to derive the symmetric key from the shared secret.
|
||||
private const int KeyDerivationIterations = 50000;
|
||||
|
||||
// Length (in bytes) of the derived AES-256 key.
|
||||
private const int DerivedKeyLength = 32;
|
||||
|
||||
internal static Random Ran
|
||||
{
|
||||
@@ -55,19 +58,7 @@ internal static class Encryption
|
||||
internal static string MyKey
|
||||
{
|
||||
get => Encryption.myKey;
|
||||
|
||||
set
|
||||
{
|
||||
if (Encryption.myKey != value)
|
||||
{
|
||||
Encryption.myKey = value;
|
||||
_ = Task.Factory.StartNew(
|
||||
() => Encryption.GenLegalKey(),
|
||||
System.Threading.CancellationToken.None,
|
||||
TaskCreationOptions.None,
|
||||
TaskScheduler.Default); // Cache the key to improve UX.
|
||||
}
|
||||
}
|
||||
set => Encryption.myKey = value;
|
||||
}
|
||||
|
||||
private static string KeyDisplayedText(string key)
|
||||
@@ -112,61 +103,72 @@ internal static class Encryption
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, byte[]> LegalKeyDictionary = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static byte[] GenLegalKey()
|
||||
private static byte[] GenLegalKey(byte[] salt)
|
||||
{
|
||||
byte[] rv;
|
||||
string myKey = Encryption.MyKey;
|
||||
|
||||
if (!LegalKeyDictionary.TryGetValue(myKey, out byte[] value))
|
||||
{
|
||||
rv = Rfc2898DeriveBytes.Pbkdf2(
|
||||
myKey,
|
||||
Common.GetBytesU(InitialIV),
|
||||
50000,
|
||||
HashAlgorithmName.SHA512,
|
||||
32);
|
||||
_ = LegalKeyDictionary.AddOrUpdate(myKey, rv, (k, v) => rv);
|
||||
}
|
||||
else
|
||||
{
|
||||
rv = value;
|
||||
}
|
||||
|
||||
return rv;
|
||||
}
|
||||
|
||||
private static byte[] GenLegalIV()
|
||||
{
|
||||
string st = InitialIV;
|
||||
int ivLength = symAl.IV.Length;
|
||||
if (st.Length > ivLength)
|
||||
{
|
||||
st = st[..ivLength];
|
||||
}
|
||||
else if (st.Length < ivLength)
|
||||
{
|
||||
st = st.PadRight(ivLength, ' ');
|
||||
}
|
||||
|
||||
return Common.GetBytes(st);
|
||||
return Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encryption.MyKey,
|
||||
salt,
|
||||
KeyDerivationIterations,
|
||||
HashAlgorithmName.SHA512,
|
||||
DerivedKeyLength);
|
||||
}
|
||||
|
||||
internal static Stream GetEncryptedStream(Stream encryptedStream)
|
||||
{
|
||||
ICryptoTransform encryptor;
|
||||
encryptor = symAl.CreateEncryptor(GenLegalKey(), GenLegalIV());
|
||||
// A fresh random salt and IV are generated for every connection and sent in the
|
||||
// clear ahead of the cipher text. Neither value is secret: deriving the symmetric
|
||||
// key still requires the shared secret. The random salt prevents an attacker from
|
||||
// pre-computing a brute-force/rainbow table that could be reused against every
|
||||
// captured connection, and the random IV avoids reusing a fixed IV across sessions.
|
||||
byte[] header = new byte[SaltSize + SymAlBlockSize];
|
||||
RandomNumberGenerator.Fill(header);
|
||||
ExchangeEncryptionHeader(encryptedStream, header, send: true);
|
||||
|
||||
byte[] salt = header[..SaltSize];
|
||||
byte[] iv = header[SaltSize..];
|
||||
|
||||
ICryptoTransform encryptor = symAl.CreateEncryptor(GenLegalKey(salt), iv);
|
||||
return new CryptoStream(encryptedStream, encryptor, CryptoStreamMode.Write);
|
||||
}
|
||||
|
||||
internal static Stream GetDecryptedStream(Stream encryptedStream)
|
||||
{
|
||||
ICryptoTransform decryptor;
|
||||
decryptor = symAl.CreateDecryptor(GenLegalKey(), GenLegalIV());
|
||||
byte[] header = new byte[SaltSize + SymAlBlockSize];
|
||||
ExchangeEncryptionHeader(encryptedStream, header, send: false);
|
||||
|
||||
byte[] salt = header[..SaltSize];
|
||||
byte[] iv = header[SaltSize..];
|
||||
|
||||
ICryptoTransform decryptor = symAl.CreateDecryptor(GenLegalKey(salt), iv);
|
||||
return new CryptoStream(encryptedStream, decryptor, CryptoStreamMode.Read);
|
||||
}
|
||||
|
||||
// Sends or receives the cleartext salt + IV header. Mirrors the tolerant socket-close
|
||||
// handling used elsewhere so an expected remote disconnect does not surface as an error.
|
||||
private static void ExchangeEncryptionHeader(Stream stream, byte[] header, bool send)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (send)
|
||||
{
|
||||
stream.Write(header, 0, header.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
stream.ReadExactly(header);
|
||||
}
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
Logger.Log($"{nameof(ExchangeEncryptionHeader)}: Exception {(send ? "writing" : "reading")} the encryption header to the socket stream: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)");
|
||||
|
||||
if (e is not EndOfStreamException && e.InnerException is not (SocketException or ObjectDisposedException))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static uint Get24BitHash(string st)
|
||||
{
|
||||
if (string.IsNullOrEmpty(st))
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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.IO;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using MouseWithoutBorders.Core;
|
||||
|
||||
namespace MouseWithoutBorders.UnitTests.Core;
|
||||
|
||||
[TestClass]
|
||||
public sealed class EncryptionTests
|
||||
{
|
||||
// Must be at least 16 characters to be accepted as a key.
|
||||
private const string TestKey = "MwbEncryptionTestKey1234";
|
||||
|
||||
// The cleartext salt (16 bytes) + IV (16 bytes) header that precedes the cipher text.
|
||||
private const int HeaderLength = Encryption.SymAlBlockSize * 2;
|
||||
|
||||
[TestInitialize]
|
||||
public void TestInitialize()
|
||||
{
|
||||
Encryption.InitEncryption();
|
||||
Encryption.MyKey = TestKey;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncryptThenDecryptShouldRoundTripPlainText()
|
||||
{
|
||||
// 48 bytes = 3 AES blocks, so the (zeros) padding adds no trailing-byte ambiguity.
|
||||
var plainText = new byte[48];
|
||||
for (var i = 0; i < plainText.Length; i++)
|
||||
{
|
||||
plainText[i] = (byte)(i + 1);
|
||||
}
|
||||
|
||||
var wire = Encrypt(plainText);
|
||||
var decrypted = Decrypt(wire, plainText.Length);
|
||||
|
||||
CollectionAssert.AreEqual(plainText, decrypted);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncryptingSamePlainTextTwiceShouldProduceDifferentBytesOnTheWire()
|
||||
{
|
||||
var plainText = new byte[48];
|
||||
|
||||
var wire1 = Encrypt(plainText);
|
||||
var wire2 = Encrypt(plainText);
|
||||
|
||||
// A fresh random salt + IV is generated for every stream, so identical plaintext
|
||||
// must never produce identical bytes on the wire. This guards against regressing
|
||||
// to a fixed salt / IV (MSRC 118042).
|
||||
CollectionAssert.AreNotEqual(wire1, wire2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EachEncryptedStreamShouldEmitAUniqueHeader()
|
||||
{
|
||||
var plainText = new byte[48];
|
||||
|
||||
var header1 = Encrypt(plainText)[..HeaderLength];
|
||||
var header2 = Encrypt(plainText)[..HeaderLength];
|
||||
|
||||
CollectionAssert.AreNotEqual(header1, header2);
|
||||
}
|
||||
|
||||
private static byte[] Encrypt(byte[] plainText)
|
||||
{
|
||||
using var transport = new MemoryStream();
|
||||
var encryptStream = (CryptoStream)Encryption.GetEncryptedStream(transport);
|
||||
try
|
||||
{
|
||||
encryptStream.Write(plainText, 0, plainText.Length);
|
||||
encryptStream.FlushFinalBlock();
|
||||
return transport.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
encryptStream.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Decrypt(byte[] wire, int plainTextLength)
|
||||
{
|
||||
using var transport = new MemoryStream(wire);
|
||||
var decryptStream = Encryption.GetDecryptedStream(transport);
|
||||
try
|
||||
{
|
||||
var buffer = new byte[plainTextLength];
|
||||
decryptStream.ReadExactly(buffer);
|
||||
return buffer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
decryptStream.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/modules/PowerScripts/Directory.Build.props
Normal file
17
src/modules/PowerScripts/Directory.Build.props
Normal 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>
|
||||
3
src/modules/PowerScripts/Directory.Build.targets
Normal file
3
src/modules/PowerScripts/Directory.Build.targets
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Empty: stops MSBuild from walking up to the repo-root targets for the prototype. -->
|
||||
</Project>
|
||||
11
src/modules/PowerScripts/Directory.Packages.props
Normal file
11
src/modules/PowerScripts/Directory.Packages.props
Normal 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>
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>PowerScripts.Core</RootNamespace>
|
||||
<AssemblyName>PowerScripts.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
114
src/modules/PowerScripts/PowerScripts.Core/PowerScriptsPaths.cs
Normal file
114
src/modules/PowerScripts/PowerScripts.Core/PowerScriptsPaths.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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><id>/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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
61
src/modules/PowerScripts/PowerScripts.Host/ConsentPrompt.cs
Normal file
61
src/modules/PowerScripts/PowerScripts.Host/ConsentPrompt.cs
Normal 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 <id></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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
481
src/modules/PowerScripts/PowerScripts.Host/Program.cs
Normal file
481
src/modules/PowerScripts/PowerScripts.Host/Program.cs
Normal 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 <id>
|
||||
/// - The Explorer context menu invokes: PowerScripts.Host.exe run <id> --files <paths>
|
||||
/// - The KBM editor / agents enumerate via: PowerScripts.Host.exe list --json
|
||||
///
|
||||
/// Usage:
|
||||
/// PowerScripts.Host list [--json] [--root <dir>]
|
||||
/// PowerScripts.Host run <id> [--files <f1> <f2> ...] [--set name=value ...] [--root <dir>]
|
||||
/// </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 <id> approve the script's current content without running it
|
||||
/// trust revoke <id> 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><id>\t<name></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;
|
||||
}
|
||||
}
|
||||
134
src/modules/PowerScripts/PowerScripts.Host/ShellRegistration.cs
Normal file
134
src/modules/PowerScripts/PowerScripts.Host/ShellRegistration.cs
Normal 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\<ext>\shell\PowerScripts</c> whose nested
|
||||
/// sub-verbs (one per matching script) invoke <c>PowerScripts.Host.exe run <id> --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);
|
||||
}
|
||||
}
|
||||
9
src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore
vendored
Normal file
9
src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Native handler build artifacts
|
||||
*.dll
|
||||
*.lib
|
||||
*.exp
|
||||
*.obj
|
||||
*.pdb
|
||||
*.ilk
|
||||
# Host publish output used by register.ps1
|
||||
hostpublish/
|
||||
@@ -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>
|
||||
15
src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd
Normal file
15
src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd
Normal 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
|
||||
4
src/modules/PowerScripts/PowerScriptsContextMenu/dll.def
Normal file
4
src/modules/PowerScripts/PowerScriptsContextMenu/dll.def
Normal file
@@ -0,0 +1,4 @@
|
||||
EXPORTS
|
||||
DllCanUnloadNow PRIVATE
|
||||
DllGetClassObject PRIVATE
|
||||
DllGetActivationFactory PRIVATE
|
||||
388
src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp
Normal file
388
src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp
Normal 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;
|
||||
}
|
||||
@@ -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).'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user