Compare commits

..

1 Commits

Author SHA1 Message Date
Gordon Lam (SH)
ed0658235b fix(peek): set correct working directory for 'Open with'
Fixes #39305

When using Peek's 'Open with' feature, the launched application now
receives the file's directory as its working directory instead of
the PowerToys installation folder.

Changes:
- Added WorkingDirectoryHelper class
- Sets WorkingDirectory to file's parent folder
- Applies to both direct open and 'Open with' dialog
2026-02-04 20:35:36 -08:00
2823 changed files with 35516 additions and 526937 deletions

View File

@@ -1 +0,0 @@
../.github/copilot-instructions.md

View File

@@ -1 +0,0 @@
../.github/agents

View File

@@ -1 +0,0 @@
../.github/prompts

View File

@@ -1 +0,0 @@
../.github/instructions

View File

@@ -1 +0,0 @@
../.github/skills

View File

@@ -1,5 +1,5 @@
# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys
properties:
resources:
- resource: Microsoft.Windows.Settings/WindowsSettings
@@ -13,11 +13,11 @@ properties:
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: vsPackage
directives:
description: Install Visual Studio 2026 Enterprise (Any edition will work)
description: Install Visual Studio 2022 Enterprise (Any edition will work)
# Requires elevation for the set operation
securityContext: elevated
settings:
id: Microsoft.VisualStudio.Enterprise
id: Microsoft.VisualStudio.2022.Enterprise
source: winget
- resource: Microsoft.VisualStudio.DSC/VSComponents
dependsOn:
@@ -29,7 +29,7 @@ properties:
securityContext: elevated
settings:
productId: Microsoft.VisualStudio.Product.Enterprise
channelId: VisualStudio.18.Release
channelId: VisualStudio.17.Release
vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig'
configurationVersion: 0.2.0

View File

@@ -1,5 +1,5 @@
# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys
properties:
resources:
- resource: Microsoft.Windows.Settings/WindowsSettings
@@ -13,11 +13,11 @@ properties:
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: vsPackage
directives:
description: Install Visual Studio 2026 Professional (Any edition will work)
description: Install Visual Studio 2022 Professional (Any edition will work)
# Requires elevation for the set operation
securityContext: elevated
settings:
id: Microsoft.VisualStudio.Professional
id: Microsoft.VisualStudio.2022.Professional
source: winget
- resource: Microsoft.VisualStudio.DSC/VSComponents
dependsOn:
@@ -29,7 +29,7 @@ properties:
securityContext: elevated
settings:
productId: Microsoft.VisualStudio.Product.Professional
channelId: VisualStudio.18.Release
channelId: VisualStudio.17.Release
vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig'
configurationVersion: 0.2.0

View File

@@ -1,5 +1,5 @@
# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys
properties:
resources:
- resource: Microsoft.Windows.Settings/WindowsSettings
@@ -13,11 +13,11 @@ properties:
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: vsPackage
directives:
description: Install Visual Studio 2026 Community (Any edition will work)
description: Install Visual Studio 2022 Community (Any edition will work)
# Requires elevation for the set operation
securityContext: elevated
settings:
id: Microsoft.VisualStudio.Community
id: Microsoft.VisualStudio.2022.Community
source: winget
- resource: Microsoft.VisualStudio.DSC/VSComponents
dependsOn:
@@ -29,7 +29,7 @@ properties:
securityContext: elevated
settings:
productId: Microsoft.VisualStudio.Product.Community
channelId: VisualStudio.18.Release
channelId: VisualStudio.17.Release
vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig'
configurationVersion: 0.2.0

View File

@@ -40,6 +40,7 @@ body:
- Other (please specify in "Steps to Reproduce")
validations:
required: true
- type: dropdown
attributes:
label: Area(s) with issue?
@@ -57,7 +58,6 @@ body:
- Environment Variables
- FancyZones
- FancyZones Editor
- Grab And Move
- File Locksmith
- "File Explorer: Preview Pane"
- "File Explorer: Thumbnail preview"
@@ -70,7 +70,6 @@ body:
- Mouse Without Borders
- New+
- Peek
- Power Display
- PowerRename
- PowerToys Run
- Quick Accent
@@ -107,13 +106,7 @@ body:
placeholder: What happened instead?
validations:
required: false
- type: upload
id: bugreportfile
attributes:
label: Upload Bug Report ZIP-file
description: Right-clicking the PowerToys tray icon in the taskbar and selecting “Report bug” generates a ZIP file containing diagnostic information about your setup and PowerToys logs, helping us better understand and troubleshoot the issue.
validations:
required: false
- id: additionalInfo
type: textarea
attributes:

View File

@@ -1,7 +1,6 @@
# COLORS
argb
Bgr
bgra
BLACKONWHITE
BLUEGRAY
@@ -17,9 +16,8 @@ LIGHTTURQUOISE
NCol
OLIVEGREEN
PALEBLUE
pargb
pbgra
SRGBTo
PArgb
Pbgra
WHITEONBLACK
@@ -30,7 +28,6 @@ RUS
AYUV
bak
HDP
Bcl
bgcode
Deflatealgorithm
@@ -41,7 +38,6 @@ Gbps
gcode
Heatshrink
Mbits
Kbits
MBs
mkv
msix
@@ -51,7 +47,6 @@ resw
resx
srt
Stereolithography
taskmgr
terabyte
UYVY
xbf
@@ -102,7 +97,6 @@ Yubico
Perplexity
Groq
svgl
devhome
# KEYS
@@ -128,7 +122,6 @@ HOLDSPACE
HOLDBACKSPACE
IDIGNORE
KBDLLHOOKSTRUCT
keydowns
keyevent
LAlt
LBUTTON
@@ -187,12 +180,6 @@ xmlutil
# Prefix
pcs
# EXPRTK / C++ MATH
ifunction
isinf
isnan
# User32.SYSTEM_METRICS_INDEX.cs
CLEANBOOT
@@ -308,8 +295,6 @@ pwa
AOT
Aot
ify
TFM
# YML
onefuzz
@@ -317,7 +302,6 @@ onefuzz
# NameInCode
leilzh
mengyuanchen
contoso
# DllName
testhost
@@ -329,7 +313,6 @@ xef
xes
PACKAGEVERSIONNUMBER
APPXMANIFESTVERSION
PROGMAN
# MRU lists
CACHEWRITE
@@ -337,61 +320,8 @@ MRUCMPPROC
MRUINFO
REGSTR
#Xaml
NVI
Storyboards
# Misc Win32 APIs and PInvokes
DEFAULTTONEAREST
INVOKEIDLIST
LCMAP
MEMORYSTATUSEX
ABE
Mdt
HTCAPTION
POSCHANGED
QPC
QUERYPOS
SETAUTOHIDEBAR
ULW
WINDOWPOS
WINEVENTPROC
WORKERW
FULLSCREENAPP
ACLO
CACLI
DOENVSUBST
FILESYSONLY
URLIS
WAITTIMEOUT
DEFAULTTONEAREST
DWRITE
LWIN
VCENTER
VREDRAW
# COM/WinRT interface prefixes and type fragments
BAlt
BShift
Cmanifest
Cmodule
Cuuid
Dng
IApplication
IDisposable
IEnum
IFolder
IInitialize
IMemory
IOle
ipreview
IProperty
IShell
ithumbnail
IVirtual
# Test frameworks
MSTEST
# PowerRename metadata pattern abbreviations (used in tests and regex patterns)
DDDD
@@ -403,12 +333,6 @@ YYY
# Unicode
precomposed
# names of characters
zwsp
# mermaid
autonumber
# GitHub issue/PR commands
azp
feedbackhub
@@ -418,20 +342,3 @@ reportbug
#ffmpeg
crf
nostdin
# Performance counter keys
engtype
Nonpaged
# XAML
Untargeted
# Program names
SEARCHHOST
SHELLEXPERIENCEHOST
SHELLHOST
STARTMENUEXPERIENCEHOST
WIDGETBOARD
# URIs
actioncenter

View File

@@ -178,9 +178,7 @@ Taras
TBM
Teutsch
tilovell
traies
Triet
udit
urnotdfs
vednig
waaverecords
@@ -194,7 +192,6 @@ ycv
yeelam
Yuniardi
yuyoyuppe
zadjii
Zeol
Zhao
Zhaopeng
@@ -209,8 +206,6 @@ Bilibili
BVID
capturevideosample
cmdow
contoso
Contoso
Controlz
cortana
devhints
@@ -226,7 +221,6 @@ Moq
mozilla
mspaint
Newtonsoft
NVIDIA
onenote
openai
Quickime
@@ -234,7 +228,6 @@ regedit
roslyn
Skia
Spotify
taskmgr
tldr
Vanara
wangyi
@@ -250,3 +243,4 @@ xamlstyler
Xavalon
Xbox
Youdao
zadjii

View File

@@ -1,422 +0,0 @@
accelscroll
acq
ADDTO
ADDTOOL
adr
Adr
ALWAYSTIP
APPLYTOSUBMENUS
ARCHMASK
archs
AUDCLNT
autocorr
avx
axisdefer
axisflip
axisstart
backlight
BEOS
bfi
BFIN
bfly
BGRX
bitmaps
bitrev
blits
Borgerding
Borland
breakc
BREAKSCR
BUFFERFLAGS
bugzilla
Cands
capturepath
cbs
centiseconds
cexp
cfx
cfy
cgem
cifx
cify
CLASSW
coeffs
colblocks
constantbuffer
coprime
cpuid
cpx
CREATEDIBSECTION
CREATESTRUCTW
crossfades
Ctl
CTLCOLOR
CTLCOLORBTN
CTLCOLORDLG
CTLCOLOREDIT
CTLCOLORLISTBOX
CTrim
CVTEPI
DBuffer
dcl
dct
ddx
ddy
Deinterleave
denoise
denoised
DEVSOURCE
DFCS
DIVSCALAR
DJGPP
dlg
dlu
dnn
DONTCARE
downsample
DRAWITEM
DRAWITEMSTRUCT
droppedband
Droppedband
DSPs
dsum
dupburst
dupsegments
DWLP
eband
ebx
ECX
EDITCONTROL
EDSP
emmintrin
EMX
ENABLEHOOK
endloop
ENDOFSTREAM
ener
enh
ettings
expectedlock
expf
fabs
fabsf
facbuf
fastscroll
FDE
ffast
FIXDIV
floorf
fmadd
fout
fstride
fxc
GETCHANNELRECT
GETCHECK
GETCOUNT
GETDISPINFO
GETSCREENSAVEACTIVE
GETSCREENSAVETIMEOUT
GETTHUMBRECT
GIFs
glu
groupshared
gru
hcfdark
hcfwhitespace
hlsl
Hsieh
hstride
HTBOTTOMRIGHT
HTHEME
htol
ICONINFORMATION
ICONWARNING
idct
IDIn
IDISHWND
ifft
igc
ilog
imad
imax
imin
immintrin
Inj
interp
inttypes
ishl
itof
jumprecover
kfft
kheight
kissfft
KSDATAFORMAT
ksize
ktime
lastg
latestcapture
ldx
LEFTNOWORDWRAP
legitjumps
lenmem
letterbox
lld
lldx
llu
llums
logfont
lookback
lpc
lpcnet
LPNMHDR
LPNMTTDISPINFO
lround
lte
luma
Luma
maj
manualdrop
maskcache
maxabs
maxcorr
MAXFACTORS
maxperiod
maxstep
memalign
memid
memneeded
MENUINFO
MFSTARTUP
mfxhw
mic
middledrop
minperiod
MIPSr
MJPEG
MMRESULT
momentumreversal
movc
mrate
mrt
MULBYSCALAR
MULC
MWERKS
mycfg
narrowstrip
nbak
nbytes
ncapture
nchw
ncm
nduplicates
nfft
NHWC
niterations
nmonitor
nnet
NONCLIENTMETRICS
NONOTIFY
nonvle
normf
nredraw
nstop
nsubpixel
ntorn
numthreads
nvw
Octasic
osc
OSCE
ovflw
OWNERDRAW
PBGRA
periodictrap
pfdc
pillarbox
playhead
pnmh
pointerreuse
PPW
prereq
PSHR
pstdint
PSWA
pwfx
QCONST
qpc
Qpc
quantums
qweight
RCSEGMODEL
RCZOOMITSCR
readback
READERF
realcapture
REFKNOWNFOLDERID
relu
reposted
RETURNCMD
rnn
rnnoise
rotateleft
rsqrt
rtcd
RTEXT
RTH
rtvs
SCALEIN
SCALEOUT
SCREENSAVE
SCRNSAVE
SCRNSAVECONFIGURE
scrnsavw
Scrnsavw
scrollramp
SCROLLSIZEGRIP
selfie
selftest
SETBARCOLOR
SETBKCOLOR
SETDEFID
SETRECT
SETSCREENSAVETIMEOUT
SETTIPSIDE
sgem
sgemv
sgv
SHAREMODE
SHAREVIOLATION
shortlist
simde
siv
slowthenfast
smallstart
SNIPOCR
softmax
sqrtf
SROUND
srvs
ssi
startuprecovery
stdint
stf
stopafter
STREAMFLAGS
SUBFROM
subias
submix
sxx
sxy
symbian
synthesising
syy
tallportal
TBTS
tci
tcsicmp
TEXTCALLBACK
TEXTMETRIC
tgsm
THIRDPARTY
tinystep
tme
toolbars
TOOLINFO
TRACKMOUSEEVENT
TRIANGLELIST
TTM
TTN
TWID
UADD
uav
uavs
uge
Unadvise
upscaled
upscales
USUB
utof
vad
vaddq
vaddvq
valgrind
Valin
vandq
vblank
vcgeq
vdup
vectorizer
VERTID
VIDCAP
vld
vle
Vle
VLE
vminq
vmlal
vmull
vqaddq
VSHR
vshrn
vsntprintf
vsnwprintf
vsync
WASAPI
WAVEFORMATEX
WAVEFORMATEXTENSIBLE
webcam
Webcam
webcams
Wextra
wfopen
WGC
wideportal
wil
WMU
wrapjump
wtol
WTSSESSION
WTSUn
wxyz
xchg
xcorr
XEnd
Xfl
Xiang
Xiph
xmmintrin
xptr
xshift
XStart
XStep
xxxy
xxyx
xxyz
xyw
xywx
xyxx
xyxz
xyzw
xyzx
xzwx
xzxx
Yfl
YInternal
yshift
YUV
yyyx
yyzw
yzw
yzwy
yzyy
Zhou
Zhu
ZMBS
zncc
Zncc
ZNCC
zrh
zwzz
zyzw
zzwz
zzzw

View File

@@ -1,3 +1,6 @@
# D2D
#D?2D
# Repeated letters
\b([a-z])\g{-1}{2,}\b
@@ -11,7 +14,7 @@
^.*\b[Cc][Ss][Pp][Ee][Ll]{2}:\s*[Dd][Ii][Ss][Aa][Bb][Ll][Ee]-[Ll][Ii][Nn][Ee]\b
# copyright
Copyright (?:\([Cc]\)|©|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+
Copyright (?:\([Cc]\)|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+
# patch hunk comments
^@@ -\d+(?:,\d+|) \+\d+(?:,\d+|) @@ .*
@@ -19,10 +22,10 @@ Copyright (?:\([Cc]\)|©|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+
index (?:[0-9a-z]{7,40},|)[0-9a-z]{7,40}\.\.[0-9a-z]{7,40}
# file permissions
(?:^|['"`\s])(?!-+\s)[-bcdLlpsw](?:[-r][-w][-Ssx]){2}[-r][-w][-SsTtx]\+?['"`\s]
['"`\s][-bcdLlpsw](?:[-r][-w][-Ssx]){2}[-r][-w][-SsTtx]\+?['"`\s]
# css fonts
\bfont(?:-family(?:[-\w+]*)|):[^;}]+
\bfont(?:-family|):[^;}]+
# css url wrappings
\burl\([^)]+\)
@@ -87,9 +90,6 @@ arn:aws:[-/:\w]+
# AWS VPC
vpc-\w+
# Azure AD
\baad\.\w{48}\b
# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there
# YouTube url
\b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]*
@@ -171,7 +171,7 @@ themes\.googleusercontent\.com/static/fonts/[^/\s"]+/v\d+/[^.]+.
GHSA(?:-[0-9a-z]{4}){3}
# GitHub actions
\buses:\s+(['"]?)[-\w.]+/[-\w./]+@[-\w.]+\g{-1}
\buses:\s+[-\w.]+/[-\w./]+@[-\w.]+
# GitLab commit
\bgitlab\.[^/\s"]*/\S+/\S+/commit/[0-9a-f]{7,16}#[0-9a-f]{40}\b
@@ -240,7 +240,7 @@ accounts\.binance\.com/[a-z/]*oauth/authorize\?[-0-9a-zA-Z&%]*
\bmedium\.com/@?[^/\s"]+/[-\w]+
# microsoft
\b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%?#]*
\b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%]*
# powerbi
\bapp\.powerbi\.com/reportEmbed/[^"' ]*
# vs devops
@@ -414,7 +414,7 @@ ipfs://[0-9a-zA-Z]{3,}
\bgetopts\s+(?:"[^"]+"|'[^']+')
# ANSI color codes
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[(?:\d+(?:;\d+)*|)m
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m
# URL escaped characters
%[0-9A-F][A-F](?=[A-Za-z])
@@ -431,7 +431,7 @@ sha\d+:[0-9a-f]*?[a-f]{3,}[0-9a-f]*
# sha-... -- uses a fancy capture
(\\?['"]|")[0-9a-f]{40,}\g{-1}
# hex runs
\b(?=(?:[a-fA-F]{0,2}\d)*[a-fA-F]{3})[0-9a-fA-F]{16,}\b
\b[0-9a-fA-F]{16,}\b
# hex in url queries
=[0-9a-fA-F]*?(?:[A-F]{3,}|[a-f]{3,})[0-9a-fA-F]*?&
# ssh
@@ -455,11 +455,7 @@ LS0tLS1CRUdJT.*
# uuid:
\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b
# unicode escaped characters (4)
\\u[0-9a-fA-F]{4}
# hex digits including css/html color classes
# hex digits including css/html color classes:
(?:[\\0][xX]|\\u\{?|[uU]\+|#x?|%23|&H)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b
# integrity
@@ -482,7 +478,7 @@ Name\[[^\]]+\]=.*
(?:(?:\b|_|(?<=[a-z]))I|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b))
# python
#\b(?i)py(?!gment|gmy|lon|ramid|ro|th)(?=[a-z]{2,})
#\b(?i)py(?!gments|gmy|lon|ramid|ro|th)(?=[a-z]{2,})
# crypt
(['"])\$2[ayb]\$.{56}\g{-1}
@@ -502,21 +498,12 @@ Name\[[^\]]+\]=.*
# go.sum
\bh1:\S+
# golang print-f-style functions
#(?i)(?<=append|comma|debug|equal|err|error|exit|fatal|format|info|log|name|panic|print|skip|scan|string|trace|true|warn|warning|wrap|write)(?:f|ln)(?:[ (]|$)
# golang regular expression
(?<!")\br".+?"
# imports
^import\s+(?:(?:static|type)\s+|)(?:[\w.]|\{\s*\w*?(?:,\s*(?:\w*|\*))+\s*\})+(?:\s+from (['"]).*?\g{-1}|)
^import\s+(?:(?:static|type)\s+|)(?:[\w.]|\{\s*\w*?(?:,\s*(?:\w*|\*))+\s*\})+
# scala modules
#("[^"]+"\s*%%?\s*){2,3}"[^"]+"
# Dataframes / NumPy
#\b(?:df|np)\.\w{3,}
# container images
image: [-\w./:@]+
@@ -546,18 +533,12 @@ content: (['"])[-a-zA-Z=;:/0-9+]*=\g{-1}
# Note that there's a high false positive rate, remove the `?=` and search for the regex to see if the matches seem like reasonable strings
(?<!['"])\b(?:B|BR|Br|F|FR|Fr|R|RB|RF|Rb|Rf|U|UR|Ur|b|bR|br|f|fR|fr|r|rB|rF|rb|rf|u|uR|ur)['"](?=[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})
# Regular expression for word breaks
#\\b(?=[a-z]{2})
# Regular expressions for (P|p)assword
\([A-Z]\|[a-z]\)[a-z]+
# Java regular expressions
Pattern\.(?:compile|matches)\(".*"
# JavaScript regular expressions
# javascript exec/test regex
/.{3,}?/[gim]*\.(?:exec|test)\(
# javascript test regex
/.{3,}/[gim]*\.test\(
# javascript match regex
\.match\(/[^/\s"]{3,}/[gim]*\s*
# javascript match regex
@@ -584,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+
regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\)
# regex choice
#\((?:\?:|)[^)|]+(?<! )\|(?!(?:jq|xargs)\b)[^)| ][^)]*\)
# \(\?:[^)]+\|[^)]+\)
# proto
^\s*(\w+)\s\g{-1} =
@@ -607,9 +588,6 @@ urn:shemas-jetbrains-com
# Debian changelog severity
[-\w]+ \(.*\) (?:\w+|baseline|unstable|experimental); urgency=(?:low|medium|high|emergency|critical)\b
# Red Hat Package management spec file dependencies
^(?:Build|)Requires: [-.\w]+
# kubernetes pod status lists
# https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase
\w+(?:-\w+)+\s+\d+/\d+\s+(?:Running|Pending|Succeeded|Failed|Unknown)\s+
@@ -664,8 +642,6 @@ PrependWithABINamepsace
>[-a-zA-Z=;:/0-9+]{3,}=</
# base64 encoded content, possibly wrapped in mime
#(?:^|[\s=;:?])[-a-zA-Z=;:/0-9+]{50,}(?:[\s=;:?]|$)
# jwt
(?:\be[wy][-a-zA-Z=;:/0-9+]+\.){2}[-_\w]+
# base64 encoded json
\beyJ[-a-zA-Z=;:/0-9+]+
# base64 encoded pkcs
@@ -703,9 +679,9 @@ systemd.*?running in system mode \([-+].*\)$
# Non-English
# Even repositories expecting pure English content can unintentionally have Non-English content... People will occasionally mistakenly enter [homoglyphs](https://en.wikipedia.org/wiki/Homoglyph) which are essentially typos, and using this pattern will mean check-spelling will not complain about them.
# .
#
# If the content to be checked should be written in English and the only Non-English items will be people's names, then you can consider adding this.
# .
#
# Alternatively, if you're using check-spelling v0.0.25+, and you would like to _check_ the Non-English content for spelling errors, you can. For information on how to do so, see:
# https://docs.check-spelling.dev/Feature:-Configurable-word-characters.html#unicode
[a-zA-Z]*[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3}[a-zA-ZÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]*|[a-zA-Z]{3,}[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]|[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3,}
@@ -717,7 +693,7 @@ systemd.*?running in system mode \([-+].*\)$
# This corpus only had capital letters, but you probably want lowercase ones as well.
\b[LN]'+[a-z]{2,}\b
# LaTeX
# latex (check-spelling >= 0.0.22)
\\\w{2,}\{
# American Mathematical Society (AMS) / Doxygen
@@ -744,6 +720,7 @@ nolint:\s*[\w,]+
# cygwin paths
/cygdrive/[a-zA-Z]/(?:Program Files(?: \(.*?\)| ?)(?:/[-+.~\\/()\w ]+)*|[-+.~\\/()\w])+
# in check-spelling@v0.0.22+, printf markers aren't automatically consumed
# printf markers
#(?<!\\)\\[nrt](?=[a-z]{2,})
# alternate printf markers if you run into latex and friends
@@ -772,12 +749,12 @@ W/"[^"]+"
# Compiler flags (Unix, Java/Scala)
# Use if you have things like `-Pdocker` and want to treat them as `docker`
#(?:^|[\t ,>"'`=\[(#])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})
#(?:^|[\t ,>"'`=(#])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})
# Compiler flags (Windows / PowerShell)
# This is a subset of the more general compiler flags pattern.
# It avoids matching `-Path` to prevent it from being treated as `ath`
#(?:^|[\t ,"'`=\[(#])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}))
#(?:^|[\t ,"'`=(#])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}))
# Compiler flags (linker)
,-B
@@ -785,7 +762,7 @@ W/"[^"]+"
# Library prefix
# e.g., `lib`+`archive`, `lib`+`raw`, `lib`+`unwind`
# (ignores some words that happen to start with `lib`)
(?:\b|_)[Ll]ib(?!era[lt])(?:re(?=office)|era|)(?!ero|erty|rar(?:i(?:an|es)|y))(?=[a-z])
(?:\b|_)[Ll]ib(?:re(?=office)|)(?!era[lt]|ero|erty|rar(?:i(?:an|es)|y))(?=[a-z])
# iSCSI iqn (approximate regex)
\biqn\.[0-9]{4}-[0-9]{2}(?:[\.-][a-z][a-z0-9]*)*\b
@@ -796,9 +773,9 @@ W/"[^"]+"
# curl arguments
\b(?:\\n|)curl(?:\.exe|)(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)*
# set arguments
\b(?:bash|(?<!\.)sh|set)(?:\s+[-+][abefimouxE]{1,2})*\s+[-+][abefimouxE]{3,}(?:\s+[-+][abefimouxE]+)*
\b(?:bash|sh|set)(?:\s+[-+][abefimouxE]{1,2})*\s+[-+][abefimouxE]{3,}(?:\s+[-+][abefimouxE]+)*
# tar arguments
\b(?:\\n|)g?tar(?:\.exe|)(?:\s-C \S+|(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+
\b(?:\\n|)g?tar(?:\.exe|)(?:(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+
# tput arguments -- https://man7.org/linux/man-pages/man5/terminfo.5.html -- technically they can be more than 5 chars long...
\btput\s+(?:(?:-[SV]|-T\s*\w+)\s+)*\w{3,5}\b
# macOS temp folders

View File

@@ -101,7 +101,6 @@
^doc/devdocs/akaLinks\.md$
^NOTICE\.md$
^src/common/CalculatorEngineCommon/exprtk\.hpp$
^src/common/UnitTests-CommonUtils/
^src/common/ManagedCommon/ColorFormatHelper\.cs$
^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$
^src/common/sysinternals/Eula/
@@ -110,8 +109,7 @@
^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/Text/.*\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$
@@ -135,14 +133,11 @@
^src/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator\.cs$
^src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HelperFiles/MarkdownWithHTMLImageTag\.txt$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/modules/ZoomIt/ZoomIt/rnnoise/
^src/modules/ZoomIt/ZoomIt/selfie_segmentation\.onnx$
^src/modules/ZoomIt/ZoomIt/ZoomIt\.idc$
^src/Monaco/
^tools/project_template/ModuleTemplate/resource\.h$
^tools/Verification scripts/Check preview handler registration\.ps1$
ignore$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/common/CalculatorEngineCommon/exprtk\.hpp$
src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs
^src/modules/powerrename/unittests/testdata/avif_test\.avif$
^src/modules/powerrename/unittests/testdata/heif_test\.heic$
^deps/spdlog-msvc-fix/

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,10 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns
Inno Setup
FFmpeg
# https://github.com/MicrosoftEdge/edge-launcher
MIcrosoftEdgeLauncherCsharp
# x64
(?:(?<=[a-df-z])x|(?<=[A-Z]X))64
# reversed irreversible binomials
\b(?:mouse down and up|low and high)\b
# marker to ignore all code on line
^.*/\* #no-spell-check-line \*/.*$
# marker for ignoring a comment to the end of the line
// #no-spell-check.*$
# JavaScript regex literals that start with \b can be reported as "b..." words.
# Example: /\bclass\s+.../
^\s*/\\[b].{3,}?/[gim]*\s*(?:\)(?:;|$)|,$)
# GitHub API header token used in code (not natural language).
\bx-ratelimit-reset\b
# Gaelic
Gàidhlig
@@ -91,14 +71,11 @@ StringComparer.OrdinalIgnoreCase\) \{.*\}
# the last line of mimetype="application/x-microsoft.net.object.bytearray.base64" things in .resx files
^\s*[-a-zA-Z=;:/0-9+]*[-a-zA-Z;:/0-9+][-a-zA-Z=;:/0-9+]*=$
# DateTime Formats
Get-Date -Format \w+|DateTime\.Now(?::|\.ToString\(")\w+
# Automatically suggested patterns
# hit-count: 5402 file-count: 1339
# IServiceProvider / isAThing
(?:(?:\b|_|(?<=[a-z]))[A-Z]|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b))
(?:(?:\b|_|(?<=[a-z]))[IT]|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b))
# hit-count: 2073 file-count: 842
# #includes
@@ -182,10 +159,6 @@ aka\.ms/[a-zA-Z0-9]+
# kubernetes crd patterns
^\s*pattern: .*$
# hit-count: 7 file-count: 3
# unicode escaped characters (4)
\\u[0-9a-fA-F]{4}
# hit-count: 5 file-count: 3
# URL escaped characters
%[0-9A-F][A-F](?=[A-Za-z])
@@ -198,10 +171,6 @@ aka\.ms/[a-zA-Z0-9]+
# medium
\bmedium\.com/@?[^/\s"]+/[-\w:/*.]+
# hit-count: 2 file-count: 2
# tar arguments
\b(?:\\n|)g?tar(?:\.exe|)(?:\s-C \S+|(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+
# hit-count: 2 file-count: 1
# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there
# YouTube url
@@ -215,9 +184,22 @@ aka\.ms/[a-zA-Z0-9]+
# curl arguments
\b(?:\\n|)curl(?:\.exe|)(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)*
# hit-count: 1 file-count: 1
# tar arguments
\b(?:\\n|)g?tar(?:\.exe|)(?:(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+
# #pragma lib
^\s*#pragma comment\(lib, ".*?"\)
# UnitTests
\[DataRow\(.*\)\]
# AdditionalDependencies
<AdditionalDependencies>.*<
# the last line of mimetype="application/x-microsoft.net.object.bytearray.base64" things in .resx files
^\s*[-a-zA-Z=;:/0-9+]*[-a-zA-Z;:/0-9+][-a-zA-Z=;:/0-9+]*=$
RegExp\(@?([`'"]).*?\g{-1}\)|(?:escapes|regEx):\s*(?:/.*/|([`'"]).*?\g{-1})|return/.*?/
# Questionably acceptable forms of `in to`
@@ -237,15 +219,13 @@ RegExp\(@?([`'"]).*?\g{-1}\)|(?:escapes|regEx):\s*(?:/.*/|([`'"]).*?\g{-1})|retu
# mount
\bmount\s+-t\s+(\w+)\s+\g{-1}\b
# C types and repeated CSS values
\s(auto|await|buffalo|center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?:\s\g{-1})+\s
\s(auto|buffalo|center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?:\s\g{-1})+\s
# C enum and struct
\b(?:enum|struct)\s+(\w+)\s+\g{-1}\b
# go templates
\s(\w+)\s+\g{-1}\s+\`(?:graphql|inject|json|yaml):
# doxygen / javadoc / .net
(?:[\\@](?:brief|defgroup|groupname|link|t?param|return|retval)|(?:public|private|\[Parameter(?:\(.+\)|)\])(?:\s+(?:static|override|readonly|required|virtual))*)(?:\s+\{\w+\}|)\s+(\w+)\s+\g{-1}\s
# C# getter/setter
\s(\w+)\s+\g{-1}\s*\{\s*[gs]et;
# macOS file path
(?:Contents\W+|(?!iOS)/)MacOS\b
@@ -294,27 +274,5 @@ St&yle
# 0x6f677548 is user name but user folder causes a flag
\bx6f677548\b
# Windows API constants and hardware interface terms
\bCOINIT[_A-Z]*\b
\bEOAC[_A-Z]*\b
\b(?:RPC_C_AUTHN_)?WINNT\b
\bUPDATEREGISTRY\b
\b(?:CDS_)?UPDATEREGISTRY\b
# Display interface terms (HDMI, DVI, DisplayPort)
\b(?:HDMI|DVI|DisplayPort)(?:-\d+)?\b
# 2D Region struct names
\bDisplayConfig2?D?Region\b
# Microsoft Store URLs and product IDs
ms-windows-store://\S+
# ANSI color codes
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m
# Special licenses text from RNNoise (BSD-style disclaimer: ``AS IS'')
``AS IS''
# Old school moniker for macOS from RNNoise
MacOS

View File

@@ -1,30 +1,23 @@
attache
aroynt.*
bellows?
^attache$
^bellows?$
benefitting
occurences?
.*dnt
dependan.*
developement
developp?e
Devers?
devex.*
devide
Devinn?[ae]
devisals?
devisors?
diables?
hasta?
hastat.*
immediatly
inisle
inital
linge
oer
^dependan.*
^develope$
^developement$
^developpe
^Devers?$
^devex
^devide
^Devinn?[ae]
^devisal
^devisor
^diables?$
^oer$
Sorce
[Ss]pae.*
Teh
untill
untilling
venders?
wether.*
^[Ss]pae.*
^Teh$
^untill$
^untilling$
^venders?$
^wether.*

View File

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

View File

@@ -163,7 +163,7 @@ configuration:
association: Collaborator
then:
- addReply:
reply: We've identified this issue as a duplicate of an existing one and are closing this thread so discussion stays in one place.<br/><br/>Please see the comment above for the link to the original tracking issue, and feel free to subscribe there for updates.
reply: Hi! We've identified this issue as a duplicate of another one that already exists on this Issue Tracker. This specific instance is being closed in favor of tracking the concern over on the referenced thread. Thanks for your report!
- closeIssue
- removeLabel:
label: Needs-Triage
@@ -233,30 +233,6 @@ configuration:
- addReply:
reply: Hi! Thanks for making us aware of the problem. We raised the issue with our internal localization team. This issue should be fixed hopefully in the next version of PowerToys.
description:
- if:
- payloadType: Issue_Comment
- commentContains:
pattern: '\/need-monitor-info'
isRegex: True
- hasLabel:
label: Product-Cursor Wrap
- or:
- activitySenderHasAssociation:
association: Owner
- activitySenderHasAssociation:
association: Member
- activitySenderHasAssociation:
association: Collaborator
then:
- removeLabel:
label: Needs-Triage
- removeLabel:
label: Needs-Team-Response
- addLabel:
label: Needs-Author-Feedback
- addReply:
reply: "To help debug your layout, please run [this script](https://github.com/microsoft/PowerToys/blob/main/src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1) and attach the generated JSON output to this thread.\n\nThis allows us to better understand the issue and investigate potential fixes."
description:
- if:
- payloadType: Issue_Comment
- commentContains:
@@ -266,5 +242,16 @@ configuration:
- addReply:
reply: Hi! Your last comment indicates to our system, that you might want to contribute to this feature/fix this bug. Thank you! Please make us aware on our ["Would you like to contribute to PowerToys?" thread](https://github.com/microsoft/PowerToys/issues/28769), as we don't see all the comments. <br /><br />_I'm a bot (beep!) so please excuse any mistakes I may make_
description:
- if:
- payloadType: Issues
- isAction:
action: Opened
- bodyContains:
pattern: 'Area\(s\) with issue\?\s*\nWorkspaces'
isRegex: True
then:
- addLabel:
label: Product-Workspaces
description:
onFailure:
onSuccess:

View File

@@ -1,377 +0,0 @@
#!/usr/bin/env node
/**
* Detects telemetry-event additions/modifications in a pull request and
* posts (or updates) a PR comment when telemetry-related changes are found.
*
* This script is executed by .github/workflows/telemetry-pr-check.yml.
* Keep both files aligned when changing trigger behavior, env usage, or messaging.
*/
const fs = require('node:fs');
const REVIEWER_LOGIN = 'chatasweetie';
const REVIEWER_MENTION = `@${REVIEWER_LOGIN}`;
const COMMENT_MARKER = '<!-- telemetry-event-check -->';
const COMMENT_BODY_WITH_PRIVACY_UPDATE = `${COMMENT_MARKER}
Thank you for contributing to PowerToys. We've detected that this PR might include a new or modified telemetry event. After this PR is merged, please follow these next steps:
- [ ] Reach out to Jessica (${REVIEWER_MENTION}) to follow up on the next steps: https://aka.ms/pt-telemetry-process
`;
const COMMENT_BODY_WITHOUT_PRIVACY_UPDATE = `${COMMENT_MARKER}
Thank you for contributing to PowerToys. We've detected that this PR might include a new or modified telemetry event. Please ensure the following before merging:
- [ ] Add your telemetry events to [DATA_AND_PRIVACY](https://github.com/microsoft/PowerToys/blob/main/DATA_AND_PRIVACY.md).md within this PR.
- [ ] Reach out to Jessica (${REVIEWER_MENTION}) to follow up on the next steps: https://aka.ms/pt-telemetry-process`;
const TELEMETRY_PATH_PATTERNS = [
/(^|\/)trace\.(h|hpp|cpp|cs)$/i,
/(^|\/)telemetry\//i,
/(^|\/)events\/.+event\.cs$/i,
/^src\/common\/Telemetry\//i,
/^src\/common\/ManagedTelemetry\//i,
/^src\/runner\/trace\.(h|cpp)$/i,
/^src\/settings-ui\/.+\/Telemetry\//i,
];
const TELEMETRY_LINE_PATTERNS = [
/TraceLoggingWriteWrapper\s*\(/,
/\bTraceLoggingWrite\s*\(/,
/\bTRACELOGGING_DEFINE_PROVIDER\b/,
/\bTraceLoggingOptionProjectTelemetry\b/,
/\bProjectTelemetryPrivacyDataTag\b/,
/\bPROJECT_KEYWORD_MEASURE\b/,
/\bRegisterProvider\s*\(/,
/\bUnregisterProvider\s*\(/,
/\bPowerToysTelemetry\.Log\.WriteEvent\s*\(/,
/\bclass\s+\w+\s*:\s*EventBase\s*,\s*IEvent\b/,
/\bclass\s+\w+\s*:\s*TelemetryBase\b/,
/\bPartA_PrivTags\b/,
/\[EventData\]/,
/\bEventName\b/,
];
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function validateRepository(repository) {
if (!/^[^/]+\/[^/]+$/.test(repository)) {
throw new Error(
`GITHUB_REPOSITORY must be in owner/repo format, received: ${JSON.stringify(repository)}`
);
}
}
function readEventPayload(eventPath) {
let raw;
try {
raw = fs.readFileSync(eventPath, 'utf8');
} catch (error) {
throw new Error(`Failed to read event payload at ${eventPath}: ${error.message}`);
}
try {
return JSON.parse(raw);
} catch (error) {
throw new Error(`Failed to parse JSON from ${eventPath}: ${error.message}`);
}
}
function resolvePullNumber(event) {
const fromPullRequest = event?.pull_request?.number;
const fromWorkflowDispatch = event?.inputs?.pr_number;
const rawPullNumber = fromPullRequest ?? fromWorkflowDispatch;
if (rawPullNumber === undefined || rawPullNumber === null || rawPullNumber === '') {
throw new Error(
'Unable to determine pull request number from event payload. Expected pull_request.number or inputs.pr_number.'
);
}
const pullNumber = Number.parseInt(String(rawPullNumber), 10);
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
throw new Error(`Invalid pull request number: ${JSON.stringify(rawPullNumber)}`);
}
return pullNumber;
}
function isTelemetryPath(filePath) {
return TELEMETRY_PATH_PATTERNS.some((pattern) => pattern.test(filePath));
}
function changedLinesFromPatch(patch) {
if (!patch) {
return [];
}
return patch
.split('\n')
.filter((line) => {
if (line.startsWith('+++') || line.startsWith('---')) {
return false;
}
return line.startsWith('+') || line.startsWith('-');
})
.map((line) => line.slice(1));
}
function hasTelemetryLineSignal(lines) {
return lines.some((line) => TELEMETRY_LINE_PATTERNS.some((pattern) => pattern.test(line)));
}
async function apiRequest(url, method = 'GET', body) {
const token = requireEnv('GITHUB_TOKEN');
let response;
try {
response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
} catch (error) {
throw new Error(`Network error during ${method} ${url}: ${error.message}`);
}
if (!response.ok) {
const text = await response.text();
const rateLimitReset = response.headers.get('x-ratelimit-reset');
const rateLimitHint =
response.status === 403 && rateLimitReset
? ` (rate limit reset at epoch ${rateLimitReset})`
: '';
throw new Error(`${method} ${url} failed (${response.status})${rateLimitHint}: ${text}`);
}
if (response.status === 204) {
return null;
}
try {
return await response.json();
} catch (error) {
throw new Error(`Failed to parse JSON response for ${method} ${url}: ${error.message}`);
}
}
async function getAllPullFiles(apiBaseUrl, repository, pullNumber) {
const files = [];
let page = 1;
while (true) {
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}/files?per_page=100&page=${page}`;
const batch = await apiRequest(url);
if (!Array.isArray(batch)) {
throw new Error(`Unexpected response while listing PR files on page ${page}.`);
}
if (batch.length === 0) {
break;
}
files.push(...batch);
if (batch.length < 100) {
break;
}
page += 1;
}
return files;
}
async function getPullRequest(apiBaseUrl, repository, pullNumber) {
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}`;
const pullRequest = await apiRequest(url);
if (!pullRequest || typeof pullRequest !== 'object') {
throw new Error('Unexpected response while fetching pull request details.');
}
return pullRequest;
}
async function ensureReviewerRequested(apiBaseUrl, repository, pullNumber, pullRequest) {
const authorLogin = String(pullRequest?.user?.login || '').toLowerCase();
const targetReviewer = REVIEWER_LOGIN.toLowerCase();
if (authorLogin === targetReviewer) {
console.log(`Skipping reviewer request: ${REVIEWER_LOGIN} is the PR author.`);
return;
}
const requestedReviewers = Array.isArray(pullRequest?.requested_reviewers)
? pullRequest.requested_reviewers
: [];
const alreadyRequested = requestedReviewers.some(
(reviewer) => String(reviewer?.login || '').toLowerCase() === targetReviewer
);
if (alreadyRequested) {
console.log(`Reviewer ${REVIEWER_LOGIN} is already requested.`);
return;
}
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}/requested_reviewers`;
try {
await apiRequest(url, 'POST', { reviewers: [REVIEWER_LOGIN] });
console.log(`Requested reviewer ${REVIEWER_LOGIN}.`);
} catch (error) {
// Reviewer request should not fail the telemetry guidance workflow.
console.warn(
`Unable to request reviewer ${REVIEWER_LOGIN}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async function findExistingTelemetryComment(apiBaseUrl, repository, pullNumber) {
let page = 1;
while (true) {
const commentsUrl = `${apiBaseUrl}/repos/${repository}/issues/${pullNumber}/comments?per_page=100&page=${page}`;
const comments = await apiRequest(commentsUrl);
if (!Array.isArray(comments)) {
throw new Error(`Unexpected response while listing issue comments on page ${page}.`);
}
const existing = comments.find(
(comment) => typeof comment.body === 'string' && comment.body.includes(COMMENT_MARKER)
);
if (existing) {
return existing;
}
if (comments.length < 100) {
return null;
}
page += 1;
}
}
function detectTelemetryChanges(files) {
const matches = [];
for (const file of files) {
const filename = file.filename || '';
const telemetryPath = isTelemetryPath(filename);
const changedLines = changedLinesFromPatch(file.patch);
const telemetryLineSignal = hasTelemetryLineSignal(changedLines);
// Some large diffs omit patch content. If the file path is telemetry-centric,
// treat it as a telemetry modification to avoid false negatives.
const patchUnavailable = !file.patch && telemetryPath;
if (telemetryPath || telemetryLineSignal || patchUnavailable) {
matches.push({
filename,
telemetryPath,
telemetryLineSignal,
patchUnavailable,
});
}
}
return matches;
}
function hasDataAndPrivacyChange(files) {
return files.some((file) => {
const filename = (file.filename || '').toLowerCase();
return filename === 'data_and_privacy.md';
});
}
async function upsertPrComment(apiBaseUrl, repository, pullNumber, body) {
const existing = await findExistingTelemetryComment(apiBaseUrl, repository, pullNumber);
if (existing) {
const updateUrl = `${apiBaseUrl}/repos/${repository}/issues/comments/${existing.id}`;
await apiRequest(updateUrl, 'PATCH', { body });
console.log(`Updated existing telemetry comment (id: ${existing.id}).`);
return;
}
const createUrl = `${apiBaseUrl}/repos/${repository}/issues/${pullNumber}/comments`;
await apiRequest(createUrl, 'POST', { body });
console.log('Created telemetry comment on PR.');
}
async function main() {
const eventPath = requireEnv('GITHUB_EVENT_PATH');
const repository = requireEnv('GITHUB_REPOSITORY');
const apiBaseUrl = process.env.GITHUB_API_URL || 'https://api.github.com';
validateRepository(repository);
let parsedApiBaseUrl;
try {
parsedApiBaseUrl = new URL(apiBaseUrl);
} catch {
throw new Error(`Invalid GITHUB_API_URL: ${JSON.stringify(apiBaseUrl)}`);
}
const event = readEventPayload(eventPath);
const pullNumber = resolvePullNumber(event);
console.log(`Event name: ${process.env.GITHUB_EVENT_NAME || 'unknown'}`);
console.log(`Repository: ${repository}`);
console.log(`PR number: ${pullNumber}`);
const files = await getAllPullFiles(parsedApiBaseUrl.origin, repository, pullNumber);
if (files.length === 0) {
console.log('No changed files found for PR; skipping telemetry comment update.');
return;
}
const matches = detectTelemetryChanges(files);
const dataAndPrivacyChanged = hasDataAndPrivacyChange(files);
console.log(`Scanned ${files.length} changed files.`);
console.log(`Telemetry matches found: ${matches.length}.`);
console.log(`DATA_AND_PRIVACY.md changed: ${dataAndPrivacyChanged}.`);
if (matches.length === 0) {
console.log('No telemetry-related additions/modifications detected.');
return;
}
for (const match of matches) {
console.log(
`- ${match.filename} (telemetryPath=${match.telemetryPath}, telemetryLineSignal=${match.telemetryLineSignal}, patchUnavailable=${match.patchUnavailable})`
);
}
try {
const pullRequest = await getPullRequest(parsedApiBaseUrl.origin, repository, pullNumber);
await ensureReviewerRequested(parsedApiBaseUrl.origin, repository, pullNumber, pullRequest);
} catch (error) {
console.warn(
'Failed to fetch PR details or request reviewer; continuing to post telemetry guidance comment.'
);
console.warn(error instanceof Error ? error.stack || error.message : error);
}
const commentBody = dataAndPrivacyChanged
? COMMENT_BODY_WITH_PRIVACY_UPDATE
: COMMENT_BODY_WITHOUT_PRIVACY_UPDATE;
await upsertPrComment(apiBaseUrl, repository, pullNumber, commentBody);
}
main().catch((error) => {
console.error('Telemetry PR check failed.');
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});

View File

@@ -1,201 +0,0 @@
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.

View File

@@ -1,331 +0,0 @@
---
name: powertoys-verification
description: "Verify PowerToys behavior end-to-end with the winapp CLI across two scenarios: (A) a module's release checklist against the installed build; (B) PR validation — derive each PR's checklist from its description + diff, then drive it against the installed build (a merged/shipped PR, or a whole release/hotfix set) or by building + sideloading the module when the PR isn't in the build yet (unmerged or not-yet-released). Drive each item via UIA invoke / Named Events / settings.json edits / clipboard / GPO / SendInput, and emit a structured PASS / FAIL / BLOCKED verdict per item with evidence (FAIL distinguishes product defects from stale/ambiguous checklist items). Use when asked to verify a module checklist, validate a PR, sign off a release/hotfix's PRs, or QA installed/sideloaded PowerToys bits. Combines generic winapp ui mechanics (references/winapp-ui-testing.md) with PT-specific recipes, per-scenario playbooks (references/scenarios/), and the helper .ps1 files shipped with this skill."
license: Complete terms in LICENSE.txt
---
## When to use this skill
Use this skill whenever you need to **verify PowerToys behavior with the winapp CLI** and emit a
structured PASS / FAIL / BLOCKED verdict with evidence (UIA enumeration, log line, settings.json
diff, screenshot, etc.). It runs in **two scenarios** that share the same drive techniques,
helpers, taxonomy and report format — and differ only on what the checklist is and what bits you test:
| Scenario | Trigger | Checklist source | Bits under test |
|---|---|---|---|
| **A — Module checklist** | "verify all `<Module>` items", "sign off Color Picker" | Supplied file (`references/release-checklist/<module>.md`) | Installed shipped artifact (read-only) |
| **B — PR validation** | "validate PR #N" (open or merged), "build it and test the fix", "verify the PRs in this release/hotfix", "sign off 0.X.Y" | **Derived** from each PR's description + diff | **Installed** shipped artifact if the code is already in the build; **build + sideload** if it isn't (unmerged / not-yet-released) |
> **Step 0 for every run — pick the scenario.** Read **`references/scenarios/index.md`** (the
> router + the "bits under test" contract + the verdict-vocabulary mapping), then read the one
> matching scenario doc (`references/scenarios/{module-checklist,pr-validation}.md`). For **B**, also
> resolve the bits sub-decision ("is the PR's code in the build under test?") before driving.
> This `SKILL.md` is the *shared engine* (drive techniques, helpers, taxonomy, pitfalls) common to both.
Each item produces a PASS / FAIL / BLOCKED verdict with evidence. For **A** the checklist is
supplied; for **B** you derive it from the PR(s). The skill is the *how* — independent of any
specific checklist.
## Required reads (in order)
1. **`references/scenarios/index.md`** — **read FIRST**: the scenario router (A/B), the
**"bits under test" contract** (installed-and-immutable, vs build-and-sideload for a PR whose code
isn't in the build under test), and the verdict-vocabulary mapping. Then read the **one** matching
scenario doc: `references/scenarios/module-checklist.md` (A) · `pr-validation.md` (B).
2. **`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.
3. **This `SKILL.md`** — the shared engine: the 3-bucket drive-technique selector (Step 2), classification taxonomy, critical pitfalls, helper-script catalog.
4. **`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`).
5. **`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`.
6. **`references/pre-flight.md`** — pre-flight checks, bootstrap snippet, state-hygiene cleanup, final wrap-up, hard rules.
7. **`references/reporting-format.md`** — per-item table template, top-of-report summary, step-table rules, anti-patterns, worked example.
8. **`references/environment-setup.md`** — RDP/sleep/screensaver/session-attachment gotchas. Cite in BLK-ENV verdicts.
9. **`references/release-checklist/<module>.md` — SCENARIO A ONLY** — the supplied checklist for the module under test (one file per module; see `references/release-checklist/index.md`). Each item carries `[ADMIN: …]` + `[CLARITY: …]` metadata. **This file IS the set of items to verify.** For B the checklist is derived from the PR(s) instead (see the scenario doc).
## 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`, `Open-PtBackgroundContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems` — open Win11's real context menu **coordinate-free** (COM-select the file → focus it → Shift+F10; coordinate right-click only as fallback), then UIA-invoke a menu item by name. **Canonical user-flow path for File Locksmith / Image Resizer / PowerRename / New+ menu-presence + launch tests** — prefer selecting a file (`Open-PtExplorerContextMenu -FileName`) over the background menu; open one menu per fresh window; assert menu TYPE + focus, not module presence. 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-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 source depends on the scenario (see references/scenarios/):
# A - read the supplied references/release-checklist/<module>.md
# B - derive 1-3 items from each PR's `gh pr view/diff`; then drive the installed bits, OR
# build+sideload if the code isn't in the build under test (pr-validation.md)
# Then iterate the 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 #12 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 #7; 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 #7)
if ([PtFg]::GetForegroundWindow() -eq [IntPtr]::Zero) { throw 'No input desktop — BLK-ENV (pitfall #7)' }
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 `references/modules/command-palette.md`). 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`. **Full profile + AppX/alias/foreground quirks: `references/modules/command-palette.md`.**
- **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 for either of the two scenarios above (verify a module checklist, or
validate a PR — open, merged, or a whole release/hotfix set). **Step 0: resolve the scenario via
`references/scenarios/index.md` and set the `BITS:` contract** (for B, also the bits sub-decision:
"is the PR's code in the build under test?") before driving anything. Scope rule: **one module per
run for Scenario A**, **one PR per report folder for B**. Resolve these placeholders:
| 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` (A's 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>` | Scenario A: total item count for the module. Scenario B: the GitHub PR number. |
**Execution order:** `references/scenarios/index.md` (pick scenario + set `BITS`) → the matching
`references/scenarios/<scenario>.md` (inputs, discipline, and build+sideload deploy steps for B when the code isn't in the build) → `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 skip Step 0 — drive nothing until you've set the scenario and the `BITS:` contract. A run that mutates the wrong bits (sideloads when the code is already installed, or drives the stale installed binary when validating unreleased code) is invalid regardless of the verdict.
- Do NOT chain multiple modules in one report (Scenario A) — one module per run. For B, one PR per report folder.
- 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. **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 #12.**
2. **GPO HKLM vs HKCU**: HKLM wins when both are set with conflicting values.
3. **HKLM `Software\Policies\PowerToys` writes require admin** — verify with `Test-PtAdmin`.
4. **`Stop-Process` is policy-blocked in this session unless you pass `-Id <int>` literally**. Always inline the PID.
5. **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.
6. **OS-reserved chords (Win+L, Win+Tab)** are consumed by Windows before any hook and cannot be injected via SendInput at all.
7. **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).
8. **`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.
9. **`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.
10. **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).
11. **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.)
12. **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 #12 — 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).
13. **Foreground the window under test, and close per-test Explorer windows — for clean recordings AND reliable screenshots.** When a module UI opens (e.g. `PowerToys.PowerRename.exe` launched with file args, or the Settings window), it can open **behind** a previously-opened Explorer/temp folder that still holds the foreground z-order, so a human watching / a screen recording sees the wrong window. Two complementary rules (**do both**):
- **(a) Foreground the target before you observe/screenshot/drive it.** Call `Force-PtForeground -AppId <appId>` (`scripts/pt-foreground-guard.ps1`) right after the window appears — e.g. `Force-PtForeground -AppId PowerToys.PowerRename` after `Start-Process …PowerToys.PowerRename.exe`. `winapp ui screenshot -w <hwnd>` captures by HWND so it *technically* works when the window is buried, but a recording only shows the top window, and SendInput / coordinate-click fallbacks need real foreground anyway. This is the **primary** fix. (CmdPal's AppX foreground-lock is the exception — see `references/modules/command-palette.md`; use `Assert-PtForegroundOrAbort`.)
- **(b) Close each Explorer window when its item's menu tests end.** The "open one menu per fresh Explorer window" rule (context-menu flow) piles up windows that fight for z-order and clutter the capture. Track the HWNDs you open and close them **per item** (not only in global cleanup): `(New-Object -ComObject Shell.Application).Windows() | Where-Object { $_.LocationURL -match '<your-fixture-tag>' } | ForEach-Object { $_.Quit() }`. Match your disposable-fixture folder name so you never close the user's own Explorer windows.
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.

View File

@@ -1,143 +0,0 @@
# 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 #7 — short pointer to this doc
- `references/pre-flight.md` pre-flight check #4 — agent reads this doc when it detects a bad env

View File

@@ -1,226 +0,0 @@
# 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.
All the machinery lives in **`scripts/pt-explorer-contextmenu.ps1`** — dot-source it and call the functions; this doc only shows the invocations, not re-implementations. Functions:
`Test-PtDesktopInteractive`, `Open-PtExplorerWindow`, `Open-PtBackgroundContextMenu`, `Open-PtExplorerContextMenu`, `Open-PtShowMoreOptionsMenu`, `Open-PtShiftRightClickMenu`, `Expand-PtModernSubmenu`, `Get-PtContextMenuItems`, `Invoke-PtContextMenuItem`, `Set-PtModuleEnabledViaSettingsUI`.
**One pattern for every menu:** an `Open-Pt…ContextMenu` opener returns a menu HWND; then `Get-PtContextMenuItems -MenuHwnd <h>` reads it and `Invoke-PtContextMenuItem -MenuHwnd <h> -ItemName <n>` acts on it — identically for the background, file, and legacy `#32768` menus.
## 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.
**Prefer selecting a real file over the folder-background menu for present/absent/icon observation.** Right-clicking a selected file (`Open-PtExplorerContextMenu -FileName <f>`) gives a **deterministic, verifiable** target (`Document.SelectedItems()`) and a stable ITEM menu, whereas the background menu depends on "focus in the file-list + nothing selected". Use `Open-PtBackgroundContextMenu` only when the entry lives **only** on the folder background (New+), or when specifically asserting background-menu presence. Two rules for either menu: **open one menu per fresh window**, and **assert the menu TYPE + focus, not module presence** — a wrong-menu open (tree node / preview pane) can still be a populated menu, so "module entry absent" alone doesn't tell you the right menu opened. Also note the Win11 menu **populates asynchronously**: base verbs (Open/Cut/Copy) render first; 3rd-party packaged entries (PowerRename, File Locksmith) land a beat later and lower — a too-early read can miss them. **Let the menu settle (~0.5s) and/or re-poll before asserting a module entry is absent.**
## Is it stable?
**Yes — with the robust variant below.** 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 OPEN step is coordinate-free too — Shift+F10 on the COM-selected item.** `Open-PtExplorerContextMenu` COM-selects the target (deterministic, verifiable via `Document.SelectedItems()`), restores keyboard focus to it (`winapp ui focus`), then presses **Shift+F10** — no pixel is clicked. A coordinate `winapp ui click --right` is kept only as a fallback (its resolved point can miss with an open preview pane / DPI change / wide Details row / screen-edge or scrolled row). Two rules keep the coordinate-free path reliable: (a) **open one menu per fresh Explorer window** — recycling a single window through many open→Esc cycles can drop later menus (menu-state decay); (b) **keyboard focus must be in the file-list** (not the nav tree / address bar), which the helper enforces via `winapp ui focus` — otherwise Shift+F10 opens the *focused pane's* menu instead (e.g. the OneDrive tree node's menu, where PowerRename is legitimately absent but File Locksmith shows — a false "entry missing"). Confirm with `winapp ui get-focused`.
**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`.)
**Window hygiene when opening one window per menu (pitfall #13).** Because you open a **fresh Explorer window per menu**, and a module UI launched from the menu (or via CLI) can appear **behind** those windows, close each Explorer window when the item's menu tests end — and `Force-PtForeground -AppId <moduleAppId>` the launched UI before observing/screenshotting. This keeps a recording on the right window and stops stale windows piling up in the z-order. Close by matching your disposable-fixture folder name so you never quit the user's own Explorer windows: `(New-Object -ComObject Shell.Application).Windows() | Where-Object { $_.LocationURL -match '<fixture-tag>' } | ForEach-Object { $_.Quit() }`.
**Other constraints:**
- **Settings for these modules live in a module-OWNED file, not the PT-store `settings.json`** — see `SKILL.md` pitfall #12. The context-menu handler reads e.g. `power-rename-settings.json` / `file-locksmith-settings.json` / `image-resizer-settings.json` / `NewPlus\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) — see **[Reading the legacy "Show more options" (`#32768`) menu](#reading-the-legacy-show-more-options-32768-menu)** below.
- 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 get its CabinetWClass HWND
$hwnd = Open-PtExplorerWindow -Path $dir
# 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)
$present = (Get-PtContextMenuItems -MenuHwnd $menu) -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).
## Enabling / disabling the module — via the Settings-UI toggle
For any *"check enable/disable of the module works"* item, flip the module through the **real Settings
UI enable switch**. Two reasons the UI toggle is the correct method:
1. **It's the faithful user flow** the checklist is describing (a user flipping the module's switch),
and it exercises the **Settings → runner IPC enable/disable path**.
2. **It takes effect live** — the context-menu entry appears/disappears the instant you flip the
toggle, no runner restart. For New+ the toggle additionally
runs the enable-time `CopyTemplateExamples` that seeds the default templates (see
`references/modules/new-plus.md`).
Use the shipped helper **`Set-PtModuleEnabledViaSettingsUI`** (`scripts/pt-explorer-contextmenu.ps1`).
It opens Settings straight on the module page via `--open-settings=<tag>`, discovers the enable
ToggleSwitch by its `[on]/[off]` state (the AutomationId carries a per-session suffix — never
hard-code it), flips it only if needed, and cross-checks the `enabled.<key>` flag for evidence:
```powershell
. "$skill\scripts\pt-explorer-contextmenu.ps1"
try {
$r = Set-PtModuleEnabledViaSettingsUI -PageTag PowerRename -Enabled $false -EnabledKey PowerRename
# $r.State -eq 'off', $r.EnabledFlag -eq $false → now assert the menu entry is GONE
$menu = Open-PtBackgroundContextMenu -ExplorerHwnd $hwnd
$gone = -not ((Get-PtContextMenuItems -MenuHwnd $menu) -match 'Rename with PowerRename')
}
finally {
Set-PtModuleEnabledViaSettingsUI -PageTag PowerRename -Enabled $true -EnabledKey PowerRename # restore
# close the Settings window the helper opened, e.g. winapp ui invoke btn-close-<id> -w $r.SettingsHwnd
}
```
Per-module arguments (`-PageTag` for `--open-settings`, `-EnabledKey` = the `enabled.<key>` to read
back as evidence — note the key uses the **display name with spaces**):
| Module | `-PageTag` | `-EnabledKey` | Menu caption to assert |
|---|---|---|---|
| PowerRename | `PowerRename` | `PowerRename` | `Rename with PowerRename` |
| File Locksmith | `FileLocksmith` | `File Locksmith` | `Unlock with File Locksmith` |
| Image Resizer | `ImageResizer` | `Image Resizer` | `Resize with Image Resizer` |
| New+ | `NewPlus` | `NewPlus` | `New+` |
> The toggle needs an unlocked interactive desktop. If the desktop is locked / RDP-minimized so the
> Settings UI can't be driven, mark the item **`BLK-ENV`** (cite `references/environment-setup.md`).
## Which menu — decided per module (see the module profile)
**Whether a module's entry lives on the file menu, the folder-background menu, or both is
module-specific — it is recorded in each `references/modules/<module>.md` (the module's
"Context-menu routing" note), not here.** This doc only provides the openers and the machinery;
the module profile says which opener to call and the exact caption to match.
## The openers & operations — the machinery
**Openers** (each returns a menu HWND):
- **`Open-PtExplorerWindow -Path <dir>`** — opens Explorer on a folder and returns its `CabinetWClass`
HWND (int), the handle the menu openers below take. (Discovery via `list-windows`; polls until the
window appears.)
- **`Open-PtBackgroundContextMenu -ExplorerHwnd <h>`** — folder-background menu via **Shift+F10 with
nothing selected**. Coordinate-free; it first **focuses the file-list** (`winapp ui focus` on the
"Items View" list) so Shift+F10 can't open the nav-tree / address-bar menu instead, then *validates* it
opened a background menu (View/Sort by/Group by) or throws. Use for **background-only** entries (New+).
- **`Open-PtExplorerContextMenu -ExplorerHwnd <h> -FileName <name>`** — a specific file's menu (the
**preferred** opener for present/absent/icon). It COM-selects the item (reliable, never opens/renames it),
restores keyboard focus to it, then opens the menu **coordinate-free via Shift+F10** (fallback:
`winapp ui click --right`, then a coordinate right-click on the row point), and **validates it got a FILE
menu (Open/Cut/Copy/Delete)** — if it only got the background menu it retries, then **throws** rather than
passing a wrong menu off as the file menu.
- **`Open-PtShowMoreOptionsMenu -MenuHwnd <modernMenu>`** — from an open modern menu, expands "Show more
options" and returns the legacy `#32768` menu's HWND (**non-extended** verbs). See [Reading the legacy menu](#reading-the-legacy-show-more-options-32768-menu).
- **`Open-PtShiftRightClickMenu -ExplorerHwnd <h> -FileName <name>`** — opens the **extended** `#32768`
menu (`CMF_EXTENDEDVERBS`) via a **genuine Shift+right-click** on the COM-selected file, and returns its
HWND. **Required** for any *"Extended context menu only"* entry — "Show more options" won't show those.
**Operations** (take any menu HWND from the openers above — modern *or* classic):
- **`Get-PtContextMenuItems -MenuHwnd <h>`** — returns the menu's item captions (present/absent).
- **`Invoke-PtContextMenuItem -MenuHwnd <h> -ItemName <name>`** — invokes an entry by caption (`$false` = absent).
> **The OPEN step is now coordinate-free.** `Open-PtExplorerContextMenu` opens the file menu with
> **Shift+F10 on the COM-selected, focus-restored item** — no pixel is clicked. The old coordinate `winapp ui click --right` is retained only as a fallback; in an *adverse*
> layout (open **preview pane** / DPI change / wide Details row / scrolled or screen-edge row) its resolved
> point can miss and open the background menu. The helper **fails loudly** rather than passing the
> background menu off as the file menu. Mitigations if even the fallbacks miss: close the preview pane
> (Alt+P) for a deterministic layout, or mark the item `BLK-ENV` — never accept the background menu as a
> stand-in for the file menu.
## Reading the legacy "Show more options" (`#32768`) menu
The two openers above (and `Get-PtContextMenuItems`) only reach the **modern** Win11 menu
(`PopupWindowSiteBridge`). The legacy `#32768` classic Win32 popup — what you get under **"Show more
options"**, or on Win10 — has a different structure and needs a different read path. **There are two
distinct ways in, and they are NOT interchangeable:**
| You need… | Opener | What it gives |
|---|---|---|
| The classic menu **without** extended verbs (e.g. "is X *also* in Show more options") | `Open-PtShowMoreOptionsMenu -MenuHwnd <modernMenu>` (invokes "Show more options") | `#32768` populated with `CMF_NORMAL` verbs. **Extended-verbs-only entries are (correctly) ABSENT here.** |
| The **extended** menu (`CMF_EXTENDEDVERBS`) — REQUIRED for any *"Extended context menu only"* / "Appear only in extended menu" entry (e.g. PowerRename L394 combos 34) | `Open-PtShiftRightClickMenu -ExplorerHwnd <h> -FileName <f>` (**genuine Shift+right-click**) | `#32768` populated with normal **+ extended** verbs. |
> **Do not use "Show more options" to look for an extended-only entry.** `Show more options` does **not**
> pass `CMF_EXTENDEDVERBS`, so an entry a module registered as extended-only is legitimately missing there —
> reading it that way yields a false "absent". Only a real **Shift+right-click** sets the flag. Neither
> keyboard `Shift+F10` nor `Show more options` yields extended verbs; the extended menu needs the Shift +
> mouse right-click, which `Open-PtShiftRightClickMenu` performs (Shift held around an element-resolved
> right-click on the COM-selected file), then resolves the `#32768` HWND.
**The one gotcha:** the `#32768` window is **not returned by `winapp ui list-windows`**. So
`Get-PtContextMenuItems` (which discovers the menu HWND *from* `list-windows`) can't reach it, and
neither can the modern openers above. That does **not** mean it's invisible to UIA — its item subtree is
fully readable once you obtain the HWND, which both openers do via Win32
`FindWindow('#32768', $null)`; then read it with `Get-PtContextMenuItems` / `winapp ui inspect -w <hwnd>`
an exact, locale-accurate present/absent signal, no OCR or screenshot guessing.
```powershell
. "$skill\scripts\pt-explorer-contextmenu.ps1"
# (a) Non-extended classic menu — via "Show more options":
$menu = Open-PtExplorerContextMenu -ExplorerHwnd $hwnd -FileName 'a.txt' # or Open-PtBackgroundContextMenu
$classic = Open-PtShowMoreOptionsMenu -MenuHwnd $menu # returns the #32768 HWND (int)
$items = Get-PtContextMenuItems -MenuHwnd $classic # SAME reader as the modern menu
# (b) EXTENDED menu — via a genuine Shift+right-click (use for extended-only entries):
$ext = Open-PtShiftRightClickMenu -ExplorerHwnd $hwnd -FileName 'a.txt' # #32768 with CMF_EXTENDEDVERBS
$items = Get-PtContextMenuItems -MenuHwnd $ext
$present = $items -match 'Rename with PowerRename' # exact caption match
# Need to screenshot the classic menu? You already have its HWND:
# winapp ui screenshot -w $classic -o <png>
```
Notes:
- **A screenshot is corroborating evidence only**, not the verifier — `winapp ui screenshot -w $classic -o <png>` is fine to attach, but the pass/fail signal is the UIA MenuItem name text above.
- **Duplicate entries are normal on Win11**: a module can surface **twice** in the classic menu (the packaged `IExplorerCommand` *and* the legacy classic `IContextMenu` handler both appear). For a present/absent assertion either satisfies it; don't treat the duplicate as a bug.
## Matching the caption
Match the module's entry by its **visible caption** (a string), NOT the AutomationId — Explorer
assigns per-session numeric IDs like `32012` whose value/order varies. **The exact caption per module
lives in that module's profile**; discover/confirm it at runtime with `Get-PtContextMenuItems`
(enable the module, open the applicable menu, 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 only under "Show more options" (classic `#32768` menu — which `list-windows` can't see) | for the modern menu, `Get-PtContextMenuItems`; for the classic menu, `Open-PtShowMoreOptionsMenu` then the same `Get-PtContextMenuItems` — see [Reading the legacy menu](#reading-the-legacy-show-more-options-32768-menu) |
| 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.

View File

@@ -1,114 +0,0 @@
# 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; **reworked 2026-07-08** — recipe reduced to *capability → control* + a Read-out notes block, self-contradiction &amp; meta-hedges removed. Rows 412 are author-derived (control IDs discover-at-runtime); confirm on the next live run |
| New+ | `new-plus.md` | ✅ written 2026-06-18 (registration-gate for menu presence; Settings-UI toggle drives template auto-copy) |
| Command Palette | `command-palette.md` | ✅ written 2026-07-07 (CmdPal AppX foreground-lock / TextChanged-broken / alias-keystroke / Esc-filtered quirks — moved out of the global SKILL.md pitfalls) |
| (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
A profile holds **only module-specific logic** an agent can't infer from the SKILL engine. It has **4 required sections + 2 optional**. Do NOT pad it with sections that have no content — omit them. No Ceiling/Don'ts sections: a PASS-rate number drifts every release, and "don'ts" are just traps phrased negatively (put them in BLOCKED traps).
**Required (always):** ① metadata header · ② Entry-paths · ③ Recipes · ④ BLOCKED traps.
**Optional (include only if non-empty):** Fixtures · Source citations.
```markdown
# <Module> — module verification profile
# ① metadata header (REQUIRED) — bootstrap facts. Drop any line that doesn't apply.
**PT module**: `<ModuleKey>` (one-line description)
**Source**: `src\modules\<dir>\`
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\<dir>\settings.json`
**Exe**: `<full path>`
**Default hotkey**: `<keys>` (+ settings ActivationShortcut path)
**Named Event**: `Local\<name>` (friendly name in pt-shared-events.ps1 catalog)
**DSC resource**: `Microsoft.PowerToys/<Name>Settings`
**Last verified**: `<build>` · `<date>` (bump whenever you re-drive the module)
## Entry-paths (try in order) # ② REQUIRED — how to launch & reach the UI, fastest first
### 1. <fastest path> <code + when to use + source citation>
### 2. <alternate path>
### 3. <last-resort path>
## Recipes — control/observation map, NOT an answer key # ③ REQUIRED
| # | Capability | Drive (control / settings key) | Observe (where result shows) |
|---|---|---|---|
| 1 | <module capability> | <AutomationId / control / settings key> | <preview / settings.json / disk / log / menu> |
> Mapping: read item → find capability row → drive the control, design your OWN inputs+assertions. No canned inputs/expected values (they go stale + invite copying). New capability ⇒ add a row.
## BLOCKED traps # ④ REQUIRED — false-block + gotcha prevention (absorbs old "Don'ts"/"gotchas")
- <mistake prior agents made → the fix>; <module quirk that misleads driving>
## Fixtures # OPTIONAL — only if the module needs canned files (else omit)
## Source citations # OPTIONAL — PT-repo file:line for surprising behavior (else inline in traps)
```
## Hygiene
- **4 required + 2 optional sections only** (header · entry-paths · recipes · BLOCKED traps; fixtures + source citations if non-empty). No Ceiling, no Don'ts — fold negative guidance into BLOCKED traps. Omit empty sections rather than writing "None".
- **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 capability → control MAP, not a mini-checklist.** Two attributes only: *Capability → Control (how to drive it)*. It must **not** carry inputs, expected outputs, or an `Observe` column — those overlap the checklist, which owns the inputs *and* the expected result. Put *where/how to read* an outcome in a short **Read-out notes** block under the table (a readout location/technique, never an expected value).
- **Tables are capability-keyed, NOT line-keyed.** Upstream checklist line numbers (`L<n>`) **must not appear** — they drift between releases. PT-source-code citations should prefer **file + symbol** (a line number is fine only as an *as-of-build* hint).
- **Cite source by file + symbol** (e.g. `Settings.cpp CSettings::Load`) where module behavior surprises (CLI guards, debounce timings, fallback chains) so reviewers can verify — symbols survive refactors, bare line numbers rot.
- **Update the profile after every verification round**; promote any new technique into the right helper script if it generalizes beyond this module.
## Filling a profile: provenance & keeping it fresh
Profiles are written by an agent and may sit unreviewed, so a hallucination can read exactly like a verified truth (that's how `power-rename.md` ended up telling agents to read a non-existent HKCR `Icon` value, contradicting its own "modern-menu-only on Win11" thesis). Guard against that with **content discipline, not hedges.**
**1. State a fact, or give the discovery instruction — never hedge.** If a value is confirmed, state it plainly. If it's a guess or *volatile* (changes per launch/build — e.g. PowerRename's per-launch `txt-textbox-XXXX` IDs), **don't write the guessed value at all** — write the runtime-discovery instruction instead ("discover the AutomationId at runtime by name/role"). **Do NOT** add `[UNVERIFIED] — confirm yourself` tags, "review notes", or any commentary about the doc's own reliability: it doesn't help the consuming agent (which re-checks at runtime anyway) and makes it distrust otherwise-good content. If something's wrong, fix it; if it's uncertain, turn it into a discovery instruction.
**2. Fill from evidence, not speculation.** Don't AI-generate a whole profile up front (that's what produced the HKCR hallucination). **Seed** a thin one (metadata + a source scan for durable facts), then let **each run add** the controls/keys it actually confirmed, correct anything that failed, and append BLOCKED traps from that run's §G retrospective. The profile grows *out of* runs.
**3. Durable vs volatile.** Durable facts (settings-file path, "modern-menu-only on Win11", the two-settings-files gotcha) belong here as plain statements. Volatile facts (per-launch/per-build IDs, layout) are **discovered at runtime**, never pinned.
**4. State each fact once.** Duplication is the #1 staleness amplifier: when a fact lives in the recipe table, an entry-path, and three BLOCKED traps, one edit leaves the others stale → contradiction. Shared mechanics go in the cross-flow doc; module facts once; everything else cross-references.
**5. Prefer symbol over line for source citations.** `Settings.cpp CSettings::Load` survives a refactor; `Settings.cpp:307` rots on the next edit. A line number is fine only as an *as-of-build* hint.
**6. Freshness.** The only provenance marker a profile carries is a compact `**Last verified**: <build> (<date>)` line in the header — no per-fact date tags in the body. Treat a profile as stale after ~2 releases or on a detected UI redesign; it should get **one human review** before it's trusted (necessary, not sufficient — the same discipline the skill applies to checklists). An optional run-start lint (do the named control IDs resolve? do the settings keys exist?) can flag drift.

View File

@@ -1,33 +0,0 @@
# Advanced Paste — module verification profile
**PT module**: `AdvancedPaste` (clipboard transform: plain/markdown/json + AI custom paste)
**Source**: `src\modules\AdvancedPaste\`
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\settings.json`
**Exe**: `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.AdvancedPaste.exe`
**Named Event**: `Local\PowerToys_AdvancedPaste_ShowUI` (friendly: `AdvancedPaste.ShowUI`)
**Default UI hotkey**: Win+Shift+V (`advanced-paste-ui-hotkey`); paste-as-plain Win+Ctrl+Alt+V
**Settings UI section keys**: IsAIEnabled, AutoCopySelectionForCustomActionHotkey, paste-as-{plain,markdown,json}-hotkey, additional-actions, custom-actions
## Entry-paths (try in order)
1. Enable module: master `settings.json` `enabled.AdvancedPaste=true` + `Restart-PtRunner` (it ships DISABLED). Then `Invoke-PtSharedEvent -Name AdvancedPaste.ShowUI`. Window title "Advanced Paste", class WinUIDesktopWin32WindowClass.
2. Paste options are a ListView: invoke `itm-pasteasplaintex-*` / `itm-pasteasmarkdown-*` / `itm-pasteasjson*` (suffix is dynamic — match by `Select-String 'pasteas...'`). They paste into the previously-focused window.
## Recipes — control/observation map
| Capability | Drive | Observe |
|---|---|---|
| Strip formatting | invoke paste-as-plain ListItem | clipboard format-diff: `HTML Format` removed, `UnicodeText` kept |
| Markdown convert | invoke paste-as-markdown | target app content (Ctrl+A,Ctrl+C read-back) |
| JSON convert (CSV/XML) | invoke paste-as-json | target content read-back |
| Color swatch | clip a `#RRGGBB` string, ShowUI | preview row shows swatch |
| Clipboard history panel | enable Windows history, seed entries, invoke `btn-clipboardhistor-*` | `itm-*` history ListItems enumerable |
| AI custom paste | `InputTxtBox` | disabled unless IsAIEnabled + provider key (BLK-EXTERNAL otherwise) |
## Common BLOCKED traps
- IsAIEnabled=false ⇒ InputTxtBox/AIProviderButton `[disabled]` → all AI items BLK-EXTERNAL.
- Clipboard-history button collapsed (`ShowClipboardHistoryButton`, MainPage.xaml:172) until Windows clipboard history is ON. Enable `HKCU\Software\Microsoft\Clipboard\EnableClipboardHistory=1` + seed entries first — it IS UIA-enumerable, not BLK-VISUAL-RENDER.
- Notepad RichEdit isn't UIA-readable; read content via Ctrl+A/Ctrl+C then `Clipboard.GetText`.
- Don't mark the clipboard-history panel BLK-VISUAL-RENDER: AP is WinUI 3 → fully UIA-capable. A control absent from the tree is Collapsed by a view-model flag, not opaque — enable its precondition + confirm via XAML `AutomationProperties` before blocking.
## Source citations
- `src\modules\AdvancedPaste\` (settings keys above)
- MainPage.xaml:172 `ShowClipboardHistoryButton`; :192 history ListView (AutomationProperties.Name per item)

View File

@@ -1,54 +0,0 @@
# Command Palette (CmdPal) — module verification profile
**PT module**: `CmdPal` / display name **"Command Palette"** (WinUI 3 packaged launcher — search box + extensible result providers/aliases)
**Source**: `src\modules\cmdpal\`
**Settings**: via `Get-CmdPalSettings` (`scripts/pt-state.ps1`) — CmdPal keeps its **own** packaged-app store, **not** the PT-store `<Module>\settings.json` that `Get-PtModuleSettings` reads.
**App identity**: packaged AppX, UIA AppId **`Microsoft.CmdPal.UI`**
**Named Event**: friendly name **`CmdPal.Show`** in the `pt-shared-events.ps1` catalog (foreground-free open)
**Helper**: **`scripts/pt-cmdpal-recycle.ps1`** — `Reset-CmdPalAppX`, `Reset-CmdPalToHome`, `Test-CmdPalDegraded`, `Invoke-CmdPalQuery` (CmdPal-specific lifecycle: TextChanged-broken recovery, BackButton navigation, AppX recycle)
> CmdPal is an **AppX WinUI 3** app, so it inherits the WinUI-island UIA limitations (SKILL.md pitfall #5)
> and an AppX foreground-lock that makes external `SetForegroundWindow` unreliable (see BLOCKED traps).
> Prefer the Named Event + the `pt-cmdpal-recycle.ps1` helpers over raw SendInput wherever possible.
## Entry-paths (try in order)
### 1. Named Event — fastest, foreground-free
```powershell
. "$skill\scripts\pt-shared-events.ps1"
Invoke-PtSharedEvent -Name 'CmdPal.Show' # opens CmdPal without keyboard/foreground
```
The deterministic, UIPI-immune way to open CmdPal (same downstream path the runner's hotkey handler signals). Verify via `winapp ui list-windows` for the `Microsoft.CmdPal.UI` window.
### 2. Query helper — drive a search + read results
```powershell
. "$skill\scripts\pt-cmdpal-recycle.ps1"
Invoke-CmdPalQuery -Text 'notepad' # types a PLAIN query; auto-handles degraded state
```
Use for plain-text queries (result list assertions). It routes through the helper so a TextChanged-broken
session is detected + recycled first. **Aliases are different — see trap below.**
### 3. Activation hotkey — only when the chord binding itself is under test
Press the configured activation shortcut (Settings → Command Palette). SendInput requires an attached
input desktop (SKILL.md pitfall #7) **and** you must foreground CmdPal first (trap below); otherwise
prefer entry-path 1.
## Recipes — control/observation map, NOT an answer key
| # | Capability | Drive (control / command) | Observe (where the result shows) |
|---|---|---|---|
| 1 | CmdPal opens & is listening | `Invoke-PtSharedEvent -Name 'CmdPal.Show'` (or the hotkey) | `Microsoft.CmdPal.UI` window appears (`winapp ui list-windows`) |
| 2 | Plain-text query returns results | `Invoke-CmdPalQuery -Text '<q>'` / `winapp ui set-value` the search box | results list updates to matching items |
| 3 | Alias engages (`=` `<` `>` `:` `$` `??` `)`) | **real keystrokes**`Assert-PtForegroundOrAbort -AppId Microsoft.CmdPal.UI``Send-PtChord` (NOT `set-value`) | the alias provider activates (e.g. `=` → calculator result) |
| 4 | Dismiss / navigate back | `winapp ui invoke BackButton` (NOT Esc — filtered) / `Reset-CmdPalToHome` | returns to home / closes |
| 5 | Recover a wedged session | `Test-CmdPalDegraded``Reset-CmdPalAppX` | CmdPal responds to input again |
| 6 | A setting round-trips | edit via Settings UI → `Get-CmdPalSettings` | the CmdPal store reflects the change |
> Mapping: read the checklist item → find the capability row → drive that control and design your own
> inputs/assertions. New capability ⇒ add a row.
## BLOCKED traps
- **AppX foreground-lock — SendInput keys leak to your terminal.** External `SetForegroundWindow` on the CmdPal AppX window is unreliable: Windows' foreground-lock blocks it after the first call, so synthetic keys go to whatever is actually foreground (often your caller). **Always `Assert-PtForegroundOrAbort -AppId Microsoft.CmdPal.UI` immediately before any SendInput**; prefer the Named Event (entry-path 1) which needs no foreground.
- **TextChanged-broken state every ~30 probes.** After heavy scripted querying the search box stops raising TextChanged and results freeze. Detect with `Test-CmdPalDegraded` and recover with `Reset-CmdPalAppX` before continuing.
- **Alias detection requires REAL keystrokes.** `winapp ui set-value` on the search box bypasses TextChanged, so alias prefixes (`=`, `<`, `>`, `:`, `$`, `??`, `)`) never fire. Use `Send-PtChord` (after `Assert-PtForegroundOrAbort`) for aliases; reserve `set-value` for plain queries.
- **Esc is filtered** by the WinUI 3 raw-input hook, so "press Esc to go back/close" doesn't work via injection. Use `winapp ui invoke BackButton` (or `Reset-CmdPalToHome`) instead.

View File

@@ -1,100 +0,0 @@
# 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; **read-only for verification** — flip enable/disable via the Settings-UI toggle)
**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.
**Context-menu routing (File Locksmith-specific):** appears on a **selected file OR a selected folder**
(verified both) — but **NOT** on the folder-background menu. Select the file/folder first
(`Select-PtExplorerFiles`, Shell COM) then open its menu with `Open-PtExplorerContextMenu -FileName <f>`
(see `references/explorer-context-menu-flow.md`). Verified visible caption:
**`Unlock with File Locksmith`** (the resource string "What's using this file?" is not what renders).
Confirm at runtime with `Get-PtContextMenuItems`.
## 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-UI toggle** via `Set-PtModuleEnabledViaSettingsUI -PageTag FileLocksmith -EnabledKey 'File Locksmith'` (see `references/explorer-context-menu-flow.md` → "Enabling / disabling the module") | `enabled."File Locksmith"`→false; entry gone (live, no restart); `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.
## 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 shipped caption is **"Unlock with File Locksmith"** (not the checklist's "What's using this file?"). The disable-removes-menu item is behaviorally verifiable; `GetState` re-reads `enabled."File Locksmith"` live (no Explorer restart). The right-click test needs an unlocked interactive desktop (idle auto-lock → `GetForegroundWindow()=0` → BLK-ENV).
```powershell
. "$skill\scripts\pt-explorer-contextmenu.ps1"
$menu = Open-PtExplorerContextMenu -ExplorerHwnd $hwnd -FileName 'target.txt' # synthetic right-click (+retry)
(Get-PtContextMenuItems -MenuHwnd $menu) -contains 'Unlock with File Locksmith' # true enabled / false disabled
Invoke-PtContextMenuItem -MenuHwnd $menu -ItemName 'Unlock with File Locksmith' # launches PowerToys.FileLocksmithUI.exe (non-elevated)
```
- Don't expect `Shell.Application.Verbs()` to show the FL entry — Win11 packaged command, invisible to classic verbs. Don't kill by name (`Stop-Process -Id <pid>`). For the disable test, flip enable/disable with `Set-PtModuleEnabledViaSettingsUI -PageTag FileLocksmith` and **restore by re-toggling on** (`-Enabled $true`); close the spawned UI + the Settings window afterward.

View File

@@ -1,82 +0,0 @@
# 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; **read-only for verification** — flip enable/disable via the Settings-UI toggle).
**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`.
**No global hotkey / no Named Event / no DSC for the engine** — entry is the Explorer menu (or direct exe launch).
**Context-menu routing (Image Resizer-specific):** appears **only on a selected image file** — NOT on
the folder-background menu, and NOT on non-image files. Select an image first (`Select-PtExplorerFiles`,
Shell COM) then open the file menu with `Open-PtExplorerContextMenu -FileName <img>` (see
`references/explorer-context-menu-flow.md`). Verified caption **`Resize with Image Resizer`**.
## 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 | **Settings-UI toggle** via `Set-PtModuleEnabledViaSettingsUI -PageTag ImageResizer -EnabledKey 'Image Resizer'` (locked desktop → `BLK-ENV`); 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.
- **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`).
- Don't expect `Shell.Application.Verbs()` to list the entry — Win11 packaged command (`CoCreate``REGDB_E_CLASSNOTREGISTERED`). Kill by `-Id <pid>` not name. Restore by re-toggling **on** via `Set-PtModuleEnabledViaSettingsUI -PageTag ImageResizer -Enabled $true`; revert any `settings.json`/`sizes.json` edits and close the Settings window.
## 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.

View File

@@ -1,76 +0,0 @@
# 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 #12 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)
Drive enable/disable through the **Settings-UI toggle** (entry-path 2 / the shared helper) — it's the faithful user flow, runs the Settings→runner IPC path, and (uniquely for New+) triggers the enable-time `CopyTemplateExamples` that seeds the default templates. Observe the gate:
- 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 #11).
> The toggle needs an unlocked interactive desktop. If it's locked / RDP-minimized so the Settings UI can't be driven, mark the item `BLK-ENV` (`references/environment-setup.md`).
### 2. Settings UI toggle via the shared helper — **required for template auto-copy**
Use `Set-PtModuleEnabledViaSettingsUI -PageTag NewPlus -EnabledKey NewPlus` (`scripts/pt-explorer-contextmenu.ps1`; see `references/explorer-context-menu-flow.md` → "Enabling / disabling the module"). It opens `--open-settings=NewPlus`, finds the enable toggle by its `[on]/[off]` state, and flips it. Manual equivalent — `winapp ui invoke <btn> -w <settingsHwnd>`:
- Enable toggle: discover by state (e.g. `btn-new-XXXX` under `NewPlusEnableToggle`) — **the enable transition runs `CopyTemplateExamples`** (Settings-UI side, `NewPlusViewModel.IsEnabled` setter), so templates are seeded only when you flip the actual toggle.
- `btn-hidethefileexte-XXXX` (Hide file extension), `btn-hideleadingdigi-XXXX` (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
`Open-PtExplorerContextMenu` (which right-clicks a *file item*) is the wrong entry. Use
**`Open-PtBackgroundContextMenu -ExplorerHwnd <h>`** (coordinate-free Shift+F10 with nothing selected;
validates it got the background menu), then expand the `New+` submenu with
**`Expand-PtModernSubmenu`** (the modern-menu sibling of `Open-PtShowMoreOptionsMenu`; both in
`scripts/pt-explorer-contextmenu.ps1`):
```powershell
$bg = Open-PtBackgroundContextMenu -ExplorerHwnd $hwnd # folder-background ("New") menu
$sub = Expand-PtModernSubmenu -MenuHwnd $bg -ItemName 'New+' ` # invokes New+, returns the child popup HWND
-ContainsItem 'Open templates' # disambiguates parent vs child PopupWindowSiteBridge
Get-PtContextMenuItems -MenuHwnd $sub # templates are 1:1 with the Templates-folder entries
# Invoke-PtContextMenuItem -MenuHwnd $sub -ItemName '<template>' # select a template (creates it in the current folder)
```
`Expand-PtModernSubmenu` reuses `Invoke-PtContextMenuItem` for the click and resolves the child
`PopupWindowSiteBridge` popup (preferring the one that contains `-ContainsItem`, else a newly-appeared
popup). 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 via **Settings-UI toggle** (`Set-PtModuleEnabledViaSettingsUI -PageTag NewPlus`; locked desktop → `BLK-ENV`) | 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
- **Default templates are only seeded by the Settings-UI enable transition** (`NewPlusViewModel.IsEnabled``CopyTemplateExamples`) — always drive enable via 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.
- Don't edit `…\PowerToys\New\settings.json` — wrong path; the file is under `NewPlus\`. Re-reads per menu build, so don't restart Explorer to apply a setting.
- Use the folder **background** ("New") menu, not `Open-PtExplorerContextMenu`; **expand the `New+` submenu** before enumerating templates. Locked desktop ⇒ menu-render items are BLK-ENV, not product FAIL.
## 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`.

View File

@@ -1,120 +0,0 @@
# 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
# MUST run non-elevated (see the elevation warning below) — from an elevated agent shell use:
# . "$skill\scripts\pt-nonelevated.ps1"; Start-PtNonElevated -Exe $peekExe -Arguments '"<file>"'
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).
> **⚠️ ELEVATION — launch Peek NON-ELEVATED for previewer tests.** If you `Start-Process` the exe
> from an **elevated** agent shell, Peek runs at high IL and its **WebView2** host fails to
> initialize: `BrowserControl.xaml.cs::PreviewWV2_Loaded` logs *"WebView2 loading failed. Object
> reference not set to an instance of an object."* and the dev/text, **markdown, PDF and HTML**
> previews spin forever on the "Busy" `ProgressBar` (only the non-WebView2 **image** previewer works).
> This is **not** a product bug — a real user's Peek is spawned non-elevated by the runner. Launch it
> the same way for CLI tests via `Start-PtNonElevated -Exe <peekExe> -Arguments '"<file>"'`
> (`scripts/pt-nonelevated.ps1`), and confirm medium IL with `Test-ProcessElevated`. Verified 2026-07-07
> on 0.100.1.0: elevated → NRE + endless Busy; non-elevated → `PreviewBrowser` + `RootWebArea` render.
> The Shell-COM + Ctrl+Space path (entry-path 2) already spawns Peek non-elevated via the runner.
**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 traps (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). See `SKILL.md` pitfall #7 and `references/environment-setup.md`. Mark BLK-ENV with mitigation citation.
Module quirks that mislead driving:
- **Elevated Peek breaks WebView2 previews.** Launching `Peek.UI.exe` from an elevated shell → high-IL Peek → `PreviewWV2_Loaded` NRE + endless "Busy" for dev/md/PDF/HTML (image still works). Launch **non-elevated** (`Start-PtNonElevated`); NOT a product bug. See entry-path 1 warning.
- **Activation-shortcut is NOT hot-reloaded.** Editing `Peek\settings.json` `ActivationShortcut` does nothing until `Restart-PtRunner`. Restart after the change AND after restoring.
- **PinButton spawns a `PopupHost` teaching-tip** (~192x63) that surfaces first in `list-windows`. Match by title suffix `- Peek`, or cache the Peek HWND before invoking PinButton.
- **Win11 Notepad tabs/session-restore** muddy open-with-default tests: spawned Notepad restores prior tabs. Match `"<file> - Notepad"` explicitly.
- **Don't `Stop-Process PowerToys.Peek.UI`** to close between iterations — bypasses the pin-save handler. Use Esc / `winapp ui invoke CloseButton`.
- CLI back-door does NOT support navigation (`_isFromCli` guard); use Shell COM + Ctrl+Space for nav. Don't OCR when UIA exposes `ImagePreview`/`PreviewBrowser`/`PinButton`.
## 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
- `src\modules\Peek\Peek.UI\PeekXAML\App.xaml.cs:106-134` — CLI arg parsing, `_isFromCli` flag, OnShowPeek call.
- `src\modules\Peek\Peek.UI\PeekXAML\Models\NavigationManager.cs``if (_isFromCli) return;` guards.
- `src\common\interop\shared_constants.h``ShowPeekEvent` name.

View File

@@ -1,135 +0,0 @@
# 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
**Last verified**: `0.100.2.0` (2026-07-08 — full 17-item run)
## Shared mechanics
For the context-menu machinery (the two openers, packaged-menu facts, BLK-ENV, honesty guard) see
**`references/explorer-context-menu-flow.md`** + **`scripts/pt-explorer-contextmenu.ps1`**. Don't
duplicate; cite by section.
**Context-menu routing (PowerRename-specific):**
- **Caption:** `Rename with PowerRename` (Win11 Tier-1). Launched exe: `PowerToys.PowerRename.exe`.
- **Which menu:** appears on **both** the selected-file menu **and** the folder-**background** menu.
Prefer **`Open-PtExplorerContextMenu -FileName <f>`** (COM-select a real file → Shift+F10, coordinate-free
and with a **verifiable** target) for present/absent/icon/extended-menu assertions. The folder-background
opener (`Open-PtBackgroundContextMenu`) also works since PR registers there, but it depends on "focus in
the file-list + nothing selected"; use it only if a test specifically asserts background-menu presence.
Open **one menu per fresh Explorer window**, and let the menu **settle ~0.5s** before reading (the PR entry
populates a beat after the base verbs).
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" }
# quote each path — Start-Process -ArgumentList joins args with spaces and does NOT auto-quote,
# so a path containing a space (e.g. "Hi World.txt") gets split into invalid args → empty file list.
$files = Get-ChildItem $tmp.FullName -File | ForEach-Object { '"{0}"' -f $_.FullName }
Start-Process "$env:LOCALAPPDATA\PowerToys\WinUI3Apps\PowerToys.PowerRename.exe" -ArgumentList $files
Start-Sleep -Milliseconds 1500
Force-PtForeground -AppId PowerToys.PowerRename # bring PR to top — else it opens BEHIND the temp Explorer window (pitfall #13); needed for recordings + reliable screenshots
# main window HWND — skip transient PopupHost/tooltip windows (see references/winapp-ui-testing.md)
$pr = (winapp ui list-windows -a PowerToys.PowerRename --json 2>$null | ConvertFrom-Json |
Where-Object { $_.title -ne 'PopupHost' } | Select-Object -First 1).hwnd
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 311 below).
### 2. Synthetic right-click + Invoke-PtContextMenuItem — for "menu entry present/absent" assertions (Recipes 12)
Use the openers from `references/explorer-context-menu-flow.md`. **Prefer selecting a real file** — it gives
a deterministic, verifiable target and opens **coordinate-free** (COM-select → Shift+F10). PowerRename also
appears on the folder background, so `Open-PtBackgroundContextMenu` is a valid alternative, but the file
opener is more robust. The menu-presence assertion is the ONE thing the CLI back-door cannot prove (it works
even if the entry is correctly hidden — the false-positive trap in that doc).
```powershell
. "$skill\scripts\pt-explorer-contextmenu.ps1"
# Disposable fixtures folder (same convention as Entry-path 1)
$fx = New-Item -ItemType Directory -Path "$env:TEMP\pr-fixture-$(Get-Random)"
'x' | Set-Content "$($fx.FullName)\a.txt"
# Open Explorer on it and grab its CabinetWClass HWND
$hwnd = Open-PtExplorerWindow -Path $fx.FullName
$menu = Open-PtExplorerContextMenu -ExplorerHwnd $hwnd -FileName 'a.txt' # COM-select + Shift+F10 (coordinate-free)
Start-Sleep -Milliseconds 500 # let the menu settle (PR populates after base verbs)
$items = Get-PtContextMenuItems -MenuHwnd $menu # returns MenuItem name strings
$has = $items | Where-Object { $_ -match 'Rename with PowerRename' }
# assert $has -> entry present
```
### 3. Shell COM classic verb (does NOT work on Win11 stock install)
```powershell
Invoke-PtShellVerb -Path "$($fx.FullName)\a.txt" -NamePattern 'PowerRename' # -> False (reuses $fx from Entry-path 2)
```
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 capability → control map (NOT a mini-checklist)
> **What this is (and isn't):** it maps each PowerRename *capability* to **which control drives it** (AutomationId / settings key) — nothing more. It is **not** the checklist: the checklist owns the *inputs* AND the *expected result*; this table only says *how to reach & poke the control*. **Where/how to read the outcome** is in the Read-out notes below — no expected values live here. That keeps the table stable across checklist rewordings; only a real UI redesign should force an edit.
| # | Capability | Control — how to drive it (AutomationId / settings key) |
|---|---|---|
| 1 | Context-menu entry present when enabled, gone when disabled | Settings-UI enable toggle: `Set-PtModuleEnabledViaSettingsUI -PageTag PowerRename -EnabledKey PowerRename` (see `references/explorer-context-menu-flow.md`) |
| 2 | Shell integration: menu **placement** + **icon** (4-combination matrix) | **Placement:** *Show PowerRename in* dropdown → "Default and extended" (`ExtendedContextMenuOnly=false`) vs "Extended only" (`=true`). **Icon:** expand that row's ˅ → **"Hide icon in context menu"** checkbox (`ShowIcon`/`bool_show_icon_on_menu`, **inverted**: checked = hide). Both persist to `power-rename-settings.json`, re-read per menu build (no Explorer restart). |
| 3 | Any search/replace option toggle (regex, match-all, case-sensitive, autocomplete, last-use) | `winapp ui invoke checkBox_regex` / `checkBox_matchAll` / `checkBox_case` (etc.); persists to `power-rename-settings.json` |
| 4 | Case conversion — lowercase / UPPERCASE / Title Case / Capitalize (mutually exclusive) | `toggleButton_lowerCase` / `upperCase` / `titleCase` / `capitalize` (single-select) |
| 5 | Which item types get renamed — include/exclude Files, Folders, Subfolders | `toggleButton_includeFiles` / `includeFolders` / `includeSubfolders` |
| 6 | Apply to whole "name + extension" / name only / extension only | `comboBox_renameParts` → items `itm-filenameonly` / `itm-extensiononly` (default = filename + extension) |
| 7 | Enumerate items — inserts an incrementing counter | `toggleButton_enumItems`; Replace accepts `${start=,increment=,padding=}`. **Prerequisite: a matching Search** — enumeration only substitutes on matched rows (empty Search → no rename, Apply disabled); use `.*` with regex on to rewrite the whole name. |
| 8 | Datetime tokens | Replace accepts `$DD` `$MMMM` `$YYYY` `$hh` `$mm` `$ss` `$fff` |
| 9 | Boost library — extended/Perl regex (e.g. lookbehind) beyond .NET | `UseBoostLib` key in `power-rename-settings.json` — read at launch, so relaunch PR after toggling |
| 10 | Per-row include/exclude in the preview | invoke a preview row's checkbox to uncheck it |
| 11 | Filter preview / select-all (NOT a column-header click — `TxtBlock_Original`/`TxtBlock_Renamed` are non-interactive labels) | `btn-filter-XXXX``button_showAll` / `button_showRenamed`; `checkBox_selectAll` |
> **Read-out notes** — where/how to read each outcome (the *expected value* always lives in the checklist, never here):
> - **Preview Renamed column** (rows 310): the Renamed value is the row's **last** `lbl-…` Text — `winapp ui inspect <itm-row> -w $hwnd --depth 4` (no `-i`; the first `lbl-…` is the Original). Or `winapp ui get-property <chkId> --property HelpText` with the **resolved** CheckBox id (e.g. `chk-0-2806` from `winapp ui search '<orig-name>' -w $hwnd --json`) — not the numeric Name (`0`) nor the `itm-…` id (both return `null`). Src: `ExplorerItem.xaml` `AutomationProperties.HelpText`→Renamed.
> - **Preview must be populated first** (rows 711): after setting Search/Replace, wait ~500 ms for the regex engine before reading rows or invoking row/filter controls.
> - **Menu presence** (rows 12): read the synthetic menu with `Get-PtContextMenuItems` (see `references/explorer-context-menu-flow.md`). Locked desktop → `BLK-ENV`.
> - **Icon, per menu** (row 2): modern tier-1 → the PR `MenuItem` gains/loses a UIA child `Image` (via `inspect`); legacy/extended `#32768` → the icon is a Win32 `MIIM_BITMAP` **invisible to UIA**, so pixel-check the row's icon gutter in a **screenshot** (not OCR). Src `PowerRenameContextMenu/dllmain.cpp GetIcon` (modern), `PowerRenameExt.cpp` `MIIM_BITMAP` (legacy).
> - **Placement** (row 2): "Extended only" → PR **absent from tier-1**, **present under a genuine Shift+right-click** (`CMF_EXTENDEDVERBS`) — NOT the non-Shift "Show more options" (`#32768`) menu.
> - **Enumerate behavior** (row 7): the counter advances **only for rows the Search matched** (`enumIndex++` on replacement; empty Search returns early — `PowerRenameRegEx.cpp`); non-matching rows don't consume an index. Formula: `start + index*increment` (0-based), zero-padded to `padding` digits (`lib/Enumerating.h`).
> **Mapping process**: read the actual checklist item → find its capability row → drive the named control (discovering control IDs at runtime where the row says so) → read the outcome via the Read-out notes → assert against the checklist's *own* expected value. No row matches ⇒ a NEW capability: drive it ad-hoc, then add a row (capability + control only).
## 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.
## BLOCKED traps
- **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`, settings-changed handler) 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, then restore.** The **context-menu** settings (ShowIcon, ExtendedContextMenuOnly) are re-read **per menu build** - no Explorer restart, just re-open the menu; the **PR UI-exe** settings (MRUEnabled, PersistState, UseBoostLib) are read at launch, so relaunch `PowerToys.PowerRename.exe`. Map (settings.json key → user-facing control): ShowIcon→"Hide icon in context menu" checkbox (**inverted**; inside the *Show PowerRename in* expander), ExtendedContextMenuOnly→"Show PowerRename in" dropdown ("Extended context menu only"), 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`.
- **Opening the extended / legacy menus** — the classic `#32768` ("Show more options") menu IS winapp-enumerable (`Open-PtShowMoreOptionsMenu``Get-PtContextMenuItems`; see `references/explorer-context-menu-flow.md` → "Reading the legacy menu"), **but it does not pass `CMF_EXTENDEDVERBS`**, so it is the **wrong observer for "extended-only"** (PR is correctly absent there). To see the extended entry, open a **genuine Shift+right-click** menu: `Open-PtShiftRightClickMenu -ExplorerHwnd <h> -FileName <f>` (Shift held around an element-resolved right-click on the COM-selected file → `FindWindow('#32768')`), then `Get-PtContextMenuItems`. *(What to assert lives in Read-out → Placement.)* Src: `PowerRenameContextMenu/dllmain.cpp` `GetState` returns `ECS_HIDDEN` (hides it from Tier-1); `PowerRenameExt.cpp` `QueryContextMenu` returns `E_FAIL` unless `CMF_EXTENDEDVERBS`.
- **PR registers on the directory *background* menu too**, but menu targeting (prefer selecting a real file over the background menu; one menu per fresh window; settle ~0.5s) is already covered once in "Context-menu routing" + Entry-path 2 above — don't re-derive it here.
- **`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"`).
- **Disk mutation is real** — run renames against `$env:TEMP\pr-test-<random>`, not real fixtures.
- **Foreground + window hygiene (pitfall #13):** the PR window (and Settings) can open **behind** the temp Explorer window you opened for the context-menu test, so a recording/screenshot shows the wrong window. **(a)** After `Start-Process …PowerToys.PowerRename.exe` (or opening Settings), call `Force-PtForeground -AppId PowerToys.PowerRename` (or `-AppId PowerToys.Settings`) before observing. **(b)** Since each menu test opens a **fresh** Explorer window, close them **per item** so they don't stack in the foreground: `(New-Object -ComObject Shell.Application).Windows() | Where-Object { $_.LocationURL -match 'pr-fixture|pr-enum' } | ForEach-Object { $_.Quit() }` (match your disposable-fixture folder name so you never close the user's Explorer windows).
- **COM cache staleness** when re-checking verbs after enable/disable — call `Reset-PtShellComCache` from `scripts/pt-shell-verbs.ps1`.
- **Don't** try `Invoke-PtShellVerb 'PowerRename'` — returns False on Win11 (no classic registration). Use synthetic menu via `Invoke-PtContextMenuItem` or direct-CLI.
- **Don't skip the synthetic-menu test for menu-presence** — the CLI back-door false-PASSes when the entry is correctly hidden (see `references/explorer-context-menu-flow.md`).
## Source citations
- `src\modules\PowerRename\dllmain.cpp` — IExplorerCommand registration (no classic HKCR shadow on Win11).
- `src\modules\PowerRename\PowerRenameUILib\` — XAML for main PR window (toggle/checkbox AutomationIds).
- `src\modules\PowerRename\PowerRenameLib\Settings.cpp` — settings.json schema canonical property names.

View File

@@ -1,122 +0,0 @@
# 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).

View File

@@ -1,72 +0,0 @@
# Advanced Paste — PowerToys release checklist
> Source: baseline split from `release-checklist-annotated.md` (0.96-aligned), extended with items
> derived from Advanced Paste PRs merged v0.96 → v0.100 (see "New / updated since v0.96"). 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
**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; rewritten concrete. Original preserved in italics.
- `[EXTERNAL: API-KEY]` - needs an AI provider key/endpoint; without one the item is `BLK-EXTERNAL`, not a failure.
---
## Advanced Paste (32 items)
### Plain text / Markdown / JSON (baseline)
> Fixture: each paste test below begins by copying source content. Re-copy fresh source before each
> paste so the source format is on the clipboard; the asserts are the "paste …" lines.
- [ ] **[ADMIN: NO]** (L866) Copy rich text (mixed colors, bold, underline); paste with Ctrl+V into a rich editor - rich text pasted with colors/formatting intact
- [ ] **[ADMIN: NO]** (L867) Copy rich text; paste with the Paste-as-Plain-Text hotkey - plain text pasted, formatting stripped
- [ ] **[ADMIN: NO]** (L868) Paste again with Ctrl+V - still plain (Paste-as-Plain leaves the clipboard as plain UnicodeText, HTML/RTF removed)
- [ ] **[ADMIN: NO]** (L870) Copy rich text; open AP, click "Paste as plain text" - plain text pasted, formatting stripped
- [ ] **[ADMIN: NO]** (L872) Copy rich text; open AP, press Ctrl+1 - plain text pasted, formatting stripped
- [ ] **[ADMIN: NO]** (L874) Open Settings, set the Paste-as-Markdown direct hotkey - chord saved to settings.json
- [ ] **[ADMIN: NO]** (L876) Copy HTML (Markdown-convertible); paste with the set hotkey - converted to Markdown
- [ ] **[ADMIN: NO]** (L878) Copy HTML; open AP, click "Paste as markdown" - converted to Markdown
- [ ] **[ADMIN: NO]** (L880) Copy HTML; open AP, press Ctrl+2 - converted to Markdown
- [ ] **[ADMIN: NO]** (L882) Open Settings, set the Paste-as-JSON direct hotkey - chord saved to settings.json
- [ ] **[ADMIN: NO]** (L884) Copy XML or CSV; paste with the set hotkey - converted to JSON
- [ ] **[ADMIN: NO]** (L886) Copy XML or CSV; open AP, click "Paste as JSON" - converted to JSON
- [ ] **[ADMIN: NO]** (L888) Copy XML or CSV; open AP, press Ctrl+3 - converted to JSON
### AI custom format (baseline)
- [ ] **[ADMIN: NO]** [EXTERNAL: API-KEY] (L890) Open Settings, set OpenAI key for AI custom format
- [ ] **[ADMIN: NO]** [EXTERNAL: API-KEY] (L891) Copy text, open AP, custom input "Insert smiley after every word", verify result
- [ ] **[ADMIN: NO]** [EXTERNAL: API-KEY] (L893) Open AP, input query, regenerate, select and paste
- [ ] **[ADMIN: NO]** (L894) Create custom actions, set hotkey, test ctrl+<num>, enable/disable, reorder
- [ ] **[ADMIN: NO]** (L895) Disable Custom format preview - result pasted right away
- [ ] **[ADMIN: NO]** (L896) Disable Enable Paste with AI - Custom Input text box disabled
### Clipboard history & enable/disable (baseline)
- [ ] **[ADMIN: NO]** (L898) Open AP, click Clipboard history, delete entry, verify gone from Win+V
- [ ] **[ADMIN: NO]** (L899) Open AP, click Clipboard history, click entry, verify put on top of Win+V
- [ ] **[ADMIN: NO]** (L900) Disable Windows clipboard history (Settings > System > Clipboard) - the Clipboard history button in AP is disabled/hidden
- [ ] **[ADMIN: NO]** (L901) Disable Advanced Paste, then press its hotkeys - nothing happens (AP window does not open; no process spawned)
### New / updated since v0.96 (PR-derived)
- [ ] **[ADMIN: NO]** (#43990) Copy a hex color string (e.g. `#3478F6`), open Clipboard history in AP - that entry shows an RGB color-swatch preview next to the value
- [ ] **[ADMIN: NO]** (#44021) Copy an image to the clipboard, open AP - image is accepted as input (image-input handling); a custom/AI action can run on it (image preview shown, no error)
- [ ] **[ADMIN: NO]** (#44767) Select text in an editor and invoke a custom-action hotkey WITHOUT pressing Ctrl+C first - AP auto-copies the selection and acts on it; clipboard is preserved
- [ ] **[ADMIN: NO]** (#46486) Repeat the auto-copy custom-action hotkey in an Electron/Chromium app (VS Code / Chrome) - selection is still auto-copied (no longer fails on those apps)
- [ ] **[ADMIN: NO]** (#45242) Settings > Advanced Paste: toggle "Enable Paste with AI" off/on - the AI paste section (custom-input text box + provider selector) hides/appears in the AP window accordingly
- [ ] **[ADMIN: NO]** [EXTERNAL: API-KEY] (#44293, #45362, #43716) Settings: switch AI provider (OpenAI / Azure OpenAI / Gemini / Foundry Local) - the configured endpoint persists per provider (Gemini does not keep an Azure placeholder; Foundry local port change is picked up on the fly)
- [ ] **[ADMIN: NO]** (#44862, #45207, #45699) Open the Advanced Paste Settings page after upgrading from an older settings.json - page loads without crashing (settings upgraded safely)
- [ ] **[ADMIN: NO]** (#44212) Clipboard history: select the same item twice - no duplicate entry is created
- [ ] **[ADMIN: NO]** (#48124) With an unreadable/locked clipboard (e.g. another app holds an open clipboard handle), trigger Paste as JSON - AP fails gracefully (no crash, error toast/no-op); then copy valid XML/CSV and confirm Paste as JSON converts correctly again

View File

@@ -1,45 +0,0 @@
# 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 applied. 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.

View File

@@ -1,29 +0,0 @@
# 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 an admin-requirement tag:
**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)
---
## File Locksmith (10 items)
- [ ] **[ADMIN: COND]** (L641) **Right-click the PowerToys installer executable** and select `Unlock with File Locksmith`. Confirm the entry is present and launches the File Locksmith window (titled **File Locksmith**). The window lists the processes using the file — **two entries** (each showing the process name + an **End task** button), because the installer starts two processes.
- [ ] **[ADMIN: COND]** (L642) Click **End task** on each listed installer process in the File Locksmith UI. Confirm each **End task** terminates that process (the installer window closes) and its **row is removed** from the list.
- [ ] **[ADMIN: COND]** (L643) **Start the installer executable again**, then click the **Reload** button (top-right refresh) in the File Locksmith UI. Confirm the newly-started process(es) using the file are **rediscovered and added** to the list.
- [ ] **[ADMIN: COND]** (L644) **Close the installer window** (let its process exit) **without** pressing Reload. Confirm File Locksmith **auto-delists** the exited process(es) within ~12 s (no manual refresh), and close the window.
- [ ] **[ADMIN: COND]** (L646) **Right-click the directory** containing the executable and select `Unlock with File Locksmith`. Confirm the entry appears and the UI lists the locking process(es) found **recursively** inside that directory tree.
- [ ] **[ADMIN: COND]** (L647) **Right-click the drive** where the executable is located and select `Unlock with File Locksmith`. Confirm the entry appears and the UI lists the locking process(es) on that volume. (You can close the PowerToys installer now.)
- [ ] **[ADMIN: COND]** (L649) With File Locksmith launched **non-elevated**, **right-click "Program Files"** (the folder holding the elevated PowerToys runner) and select `Unlock with File Locksmith`. Confirm **`PowerToys.exe` does NOT appear** in the list — a non-elevated File Locksmith cannot read the higher-integrity (elevated) runner process.
- [ ] **[ADMIN: YES]** (L650) In that same non-elevated File Locksmith window, click **Restart as an administrator** (shield button, shown only when non-elevated) and approve the UAC prompt. Confirm that after relaunching **elevated** (title becomes **Administrator: File Locksmith**), **`PowerToys.exe` now appears** in the list — the elevated File Locksmith can see higher-integrity processes.
- [ ] **[ADMIN: YES]** (L651) **Right-click the drive where Windows is installed** and select `Unlock with File Locksmith`. With the (large) process list shown, **scroll all the way down and back up** and confirm File Locksmith **does not crash** while rendering all those entries. **Repeat** after clicking **Restart as an administrator**, and confirm no crash in the elevated view either.
- [ ] **[ADMIN: COND]** (L652) **Disable** File Locksmith in PowerToys Settings. Right-click a file and confirm `Unlock with File Locksmith` is **absent** from **both** the Win11 tier-1 (modern) menu **and** the classic "Show more options" (`#32768`) menu, while sibling PowerToys entries (e.g. `Rename with PowerRename`) **remain present** — proving the menu still renders and only File Locksmith was gated out (not a render failure). **Re-enable** and confirm the entry **reappears** in both menus.

View File

@@ -1,44 +0,0 @@
# 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 an admin-requirement tag:
**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)
## Fixtures & conventions
- Prepare test images at a **known DPI** (the pixel outputs below assume **96 DPI**): `landscape 1200×800`, `portrait 800×1200`, `small 100×100`, `square 400×400`, `anim.gif` (animated), and three `batch*` images `1000×1000`.
- Default presets are **Small 854×480, Medium 1366×768, Large 1920×1080, Phone 320×568** — all **Fit**, unit **Pixel**.
- Fit modes: **Fit** = scale by `min(scaleX,scaleY)` (letterbox, no crop); **Fill** = scale by `max(scaleX,scaleY)` then centered-crop to the box; **Stretch** = apply scaleX and scaleY independently (distorts).
- Unit → pixels: **Pixel** = literal value; **Percent** = `value/100 × original` (DPI-independent); **Inch** = `value × DPI`; **Centimeter** = `value × DPI / 2.54`.
- Always resize **copies** in a disposable folder unless the item is specifically testing in-place replace.
---
## Image Resizer (18 items)
- [ ] **[ADMIN: NO]** (L309) In PowerToys Settings, **disable** Image Resizer. Right-click an image and confirm `Resize with Image Resizer` is **absent** from **both** the Win11 tier-1 (modern) menu **and** the classic "Show more options" (`#32768`) menu, while sibling PowerToys entries (e.g. `Rename with PowerRename`, `Unlock with File Locksmith`) **remain present** — proving the menu still renders and only the Image Resizer command was gated out (not a render failure).
- [ ] **[ADMIN: NO]** (L310) **Enable** Image Resizer. Right-click an image and confirm `Resize with Image Resizer` is **present, with its icon**, in **both** the Win11 tier-1 (modern) menu **and** the classic "Show more options" (`#32768`) menu. Invoking the entry launches `PowerToys.ImageResizer.exe` on the selection (window titled "Image Resizer").
- [ ] **[ADMIN: NO]** (L311) In Settings → Image Resizer → *Image sizes*, **remove** a built-in preset (e.g. `Phone`) and **add** a custom size (e.g. `Web` = 1024×768 px, Fit). Open the Image Resizer window from the context menu and open the size selector; confirm the removed preset (`Phone`) is **gone** and the added preset (`Web`) is **listed** — the presets round-trip from settings to the window.
- [ ] **[ADMIN: NO]** (L312) Select a **single** `1200×800` image and resize with the **Small** preset (854×480, Fit). Confirm exactly one output `<name> (Small).jpg` is produced at **720×480** (Fit scale = min(854/1200, 480/800) = 0.6 → 720×480) and the source file is untouched.
- [ ] **[ADMIN: NO]** (L313) Select **three** `1000×1000` images and resize them in **one** operation with the **Small** preset (854×480, Fit). Confirm all three outputs `<name> (Small).jpg` are produced, each at **480×480** (Fit = min(854/1000, 480/1000) = 0.48 → 480×480).
- [ ] **[ADMIN: NO]** (L314) Open Image Resizer on an **animated `.gif`** and confirm the yellow InfoBar warning **"Gif files with animations may not be correctly resized."** appears (shown whenever any selected file has a `.gif` extension).
- [ ] **[ADMIN: NO]** (L316) Resize a `1200×800` image to a **custom 400×400** target with the **Fill** mode. Confirm output is exactly **400×400**, produced by scaling with max(400/1200, 400/800) = 0.5 (→600×400) then **centered-cropping** to 400×400 (fills the box, no letterboxing, overflow cropped).
- [ ] **[ADMIN: NO]** (L317) Resize a `1200×800` image to a **custom 400×400** target with the **Fit** mode. Confirm output is **400×267** — aspect ratio preserved via min-scale (0.333), letterboxed inside the box, **not** cropped.
- [ ] **[ADMIN: NO]** (L318) Resize a `1200×800` image to a **custom 400×400** target with the **Stretch** mode. Confirm output is exactly **400×400** by applying scaleX=0.333 and scaleY=0.5 **independently** (image distorted). Contrast: same target gives 400×267 with Fit and 400×400-via-crop with Fill.
- [ ] **[ADMIN: NO]** (L320) Resize using unit **Centimeters**: on a `1200×800` image at **96 DPI**, custom `10cm × 5cm` with **Stretch**. Confirm output = **378×189** (cm→px = value × DPI / 2.54 → 10×96/2.54 ≈ 378, 5×96/2.54 ≈ 189). At another DPI the output scales with DPI (e.g. at 120 DPI → 472×236).
- [ ] **[ADMIN: NO]** (L321) Resize using unit **Inches**: on a `1200×800` image at **96 DPI**, custom `4in × 3in` with **Stretch**. Confirm output = **384×288** (inch→px = value × DPI → 4×96, 3×96). At 120 DPI → 480×360.
- [ ] **[ADMIN: NO]** (L322) Resize using unit **Percent**: on a `1200×800` image, width = **50%**. Confirm output = **600×400** (percent→px = value/100 × original, DPI-independent; height scales by the same 50%).
- [ ] **[ADMIN: NO]** (L323) Resize using unit **Pixels**: on a `1200×800` image, custom `500×300` with **Stretch**. Confirm output = exactly **500×300** (pixel target used literally).
- [ ] **[ADMIN: NO]** (L325) Set Filename format to `%1 - %2 - %3 - %4 - %5 - %6` and resize a `1200×800` image with the **Small** preset. Confirm the output filename is `<orig-name> - Small - 854 - 480 - 720 - 480.jpg`, where **%1**=original filename, **%2**=size/preset name, **%3**=selected width, **%4**=selected height, **%5**=actual output width, **%6**=actual output height.
- [ ] **[ADMIN: NO]** (L326) Check **Use original date modified**. Set a source image's last-modified time to a known past date (e.g. `2020-01-15 08:30`), resize it, and confirm the **output's** modified date **equals the source's** (`2020-01-15 08:30`). Control: with the option **unchecked**, the output's modified date is the **current** time instead.
- [ ] **[ADMIN: NO]** (L327) Check **Make pictures smaller but not larger**. Resize a **100×100** image with a target larger than it (e.g. Small 854×480) and confirm the output stays **100×100** (never enlarged). Control: a larger `1200×800` image with the same target is **still shrunk** (→720×480).
- [ ] **[ADMIN: NO]** (L328) Check **Resize the original pictures (don't create copies)**. Resize a `1200×800` image with the **Small** preset and confirm the original file is **overwritten in place** at **720×480** and **no** separate `(Small)` copy is created — the folder still holds exactly one file with the original name.
- [ ] **[ADMIN: NO]** (L329) **Uncheck** "Ignore the orientation of pictures" and resize a **landscape** `1200×800` image with a **portrait** target `100×200` (Fit, Pixel): confirm the target is applied **as-is****100×67** (no swap). Then **check** the option and repeat: the target is **swapped to 200×100** to match the source orientation → **150×100**.

View File

@@ -1,15 +0,0 @@
# 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 |
|---|---:|---|
| Advanced Paste | 41 | `advanced-paste.md` |
| 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 | 17 | `power-rename.md` |

View File

@@ -1,33 +0,0 @@
# New+ — PowerToys release checklist
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06); items rewritten to be
> definitive against the New+ sign-off runs (`verify-NewPlus-*`) and `../modules/new-plus.md`.
> One module per file.
## Legend
Each item is annotated with an admin-requirement tag:
**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)
> **Note:** New+ appears on the folder-**background** ("New") menu and **only** on the Win11 tier-1
> (modern) context menu — **not** the classic "Show more options" (`#32768`) menu, so do not assert
> both tiers as you would for PowerRename/Image Resizer. See `../modules/new-plus.md` for driving
> details (settings paths, default-template source, and the synthetic-menu flow).
---
## New+ (9 items)
- [ ] **[ADMIN: NO]** (L969) **Enable** New+ in PowerToys Settings. Right-click empty space inside a folder to open the Win11 tier-1 background ("New") menu and confirm the **`New+`** submenu is **present**; expanding it shows at least **`Open templates`**. (New+ is Win11 tier-1 background-menu only; may need an Explorer restart the first time. Corroborating signals: CLSID `{FF90D477-…}` registered under `HKCU:\Software\Classes\CLSID`, and the shell-ext log line `New+ context menu registered`.)
- [ ] **[ADMIN: NO]** (L971) **Disable** New+ via the Settings toggle. Right-click empty space in a folder and confirm **`New+`** is **absent** from the background menu, while the rest of the "New" menu still renders (proving only New+ was gated, not a render failure). Corroborating: the CLSID `{FF90D477-…}` is **unregistered** (`HKCU` key gone) and the log shows `New+ context menu unregistered` (the sparse package itself stays `Status Ok` — hidden dynamically). **Re-enable** and confirm `New+` reappears.
- [ ] **[ADMIN: NO]** (L973) **Delete the entire Templates folder** (`%LOCALAPPDATA%\Microsoft\PowerToys\NewPlus\Templates`), then open the New+ submenu (building the menu triggers the shell-ext to recreate the folder). Confirm the Templates folder is **recreated and empty** (0 items) and the submenu offers **only `Open templates`** (no template items). (This is the shell-ext `create_folder_if_not_exist` path — distinct from L977's default-templates copy, which only happens via the Settings-UI enable transition.)
- [ ] **[ADMIN: NO]** (L974) Copy a **file** template (e.g. `MyTemplate.txt`) into the Templates folder. Open the New+ submenu and confirm a matching item **appears** (caption `MyTemplate` with the extension hidden if *Hide extension* is on, else `MyTemplate.txt`). **Select** the item and confirm the file **`MyTemplate.txt` is created** in the current folder (copied via `SHFileOperation FO_COPY`) and enters inline rename mode.
- [ ] **[ADMIN: NO]** (L975) Copy a **folder-with-files** template into the Templates folder (e.g. `MyFolder\` containing `fileA.txt` and `sub\fileB.txt`). Open the New+ submenu and confirm **`MyFolder`** appears. **Select** it and confirm the folder is created **whole and recursively** in the current folder — i.e. `MyFolder\fileA.txt` **and** `MyFolder\sub\fileB.txt` all exist.
- [ ] **[ADMIN: NO]** (L976) **Empty** the Templates folder (delete all contents but keep the folder itself). Open the New+ submenu and confirm **no template items** are listed — only the always-present **`Open templates`** command remains.
- [ ] **[ADMIN: NO]** (L977) With the Templates folder still **empty** (from L976), **disable** and then **re-enable** New+ via the Settings toggle. Confirm re-enabling **copies the default example templates** into the Templates folder — `Example folder\` (containing `Example txt file.txt` and `Another example txt file.txt`) and `Any files or folders placed in the template folder are available via New+.txt` — and that these appear in the New+ submenu. (The copy is driven by `CopyTemplateExamples` on the Settings-UI enable transition, and only when the folder was empty; a runner-only restart does **not** copy templates.)
- [ ] **[ADMIN: NO]** (L979) Test **Hide template filename extension** (Settings → New+; key `HideFileExtension`). With a `MyTemplate.txt` template: **ON** → the submenu caption is **`MyTemplate`** (extension hidden); **OFF** → the caption is **`MyTemplate.txt`**. In **both** cases the **created file keeps its extension** (`MyTemplate.txt`) — the option affects the menu caption only. Takes effect without an Explorer restart (handler re-reads settings per menu build).
- [ ] **[ADMIN: NO]** (L980) Test **Hide template filename starting digits, spaces and dots** (key `HideStartingDigits`). Use a digit-prefixed template `01. Digits.txt` (set *Hide extension* OFF to isolate this option): **ON****both** the submenu caption **and** the created filename are **`Digits.txt`** (leading `01. ` stripped from both); **OFF** → both are **`01. Digits.txt`** (preserved). Unlike *Hide extension*, this transform applies to the created filename as well as the caption.

View File

@@ -1,59 +0,0 @@
# Peek — PowerToys release checklist
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06); items rewritten to be
> definitive against the Peek sign-off runs (`verify-Peek-*`) and `../modules/peek.md`.
> One module per file.
## Legend
Each item is annotated with an admin-requirement tag:
**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)
## Fixtures & conventions
- **Opening Peek**: select a file in Explorer and press **Ctrl+Space** (default); Peek opens a window
titled **`<file> - Peek`**.
- **Prepare a fixtures folder** with one file per previewer type, each carrying an in-file marker so you
can confirm the *content* rendered (not just that a pane appeared): an **image** (PNG), a **source/text**
file (e.g. `.cs`), a **markdown** file (H1 + bold + bullet list), a **PDF** with known marker text, an
**HTML** file with an `<h1>` marker, a **`.zip`** containing ≥1 file, and an **unsupported** file
(e.g. `.xyz` of random bytes). For the pin/position items also prepare **3 differently-sized images**
(e.g. 320×240, 800×600, 1920×1080).
- See `../modules/peek.md` for driving mechanics (CLI back-door `PowerToys.Peek.UI.exe <file>`,
Shell-COM multi-select, chord codes, and the exact UIA selectors `ImagePreview` / `PreviewBrowser` /
`PinButton` / `LaunchAppButton`).
---
## Peek (18 items)
> **L697L703 preview the following file types.** For each, open Peek on the fixture and confirm the window `<file> - Peek` shows the expected previewer rendering the fixture's content.
- [ ] **[ADMIN: NO]** (L697) **Image** — Peek an image (e.g. `small-image.png`, 200×150). Confirm the **image previewer** renders the bitmap, sized to the source, with the pin + "Open with" buttons visible.
- [ ] **[ADMIN: NO]** (L698) **Text or dev file** — Peek a source file (e.g. `Program.cs`). Confirm the **developer/text previewer** renders the code with **syntax highlighting and line numbers** (not raw plain text, not blank).
- [ ] **[ADMIN: NO]** (L699) **Markdown file** — Peek a `.md` file containing an H1, bold text, and a bullet list. Confirm the preview renders **formatted markdown** (H1 styled, bold applied, list bulleted) — not the raw `#`/`**` source.
- [ ] **[ADMIN: NO]** (L700) **PDF** — Peek a 1-page PDF with known marker text. Confirm the **PDF page renders** with the marker text visible and a PDF toolbar (zoom / page nav), not an error.
- [ ] **[ADMIN: NO]** (L701) **HTML** — Peek an `.html` file whose `<h1>` contains a marker. Confirm the HTML is **rendered** (heading shown as a styled heading), not shown as raw markup.
- [ ] **[ADMIN: NO]** (L702) **Archive files (.zip, .tar, .rar)** — Peek a `.zip` containing ≥1 file. Confirm the **archive previewer** lists the entries in a tree and shows directory/file counts + sizes (e.g. `0 directories | 1 files | … bytes`). The same previewer handles `.tar`/`.rar`.
- [ ] **[ADMIN: NO]** (L703) **Unsupported file** — Peek a file of an unhandled type (e.g. `unsupported.xyz` / `.exe`). Confirm the **unsupported-file view** is shown with **File Type / Size / Date Modified** metadata (and a generic icon) instead of a previewer, and Peek does not crash.
> **L706L709 pin / position behavior.** Use a selection of the 3 differently-sized images; move the window with a known position/size and read the window rect to assert.
- [ ] **[ADMIN: NO]** (L706) **Pin, switch between different-sized images** — Open Peek on the 3-image selection, **pin** the window (pin toggle), move it to a known position/size, then switch between the images. Confirm the window stays at the **same position and size** across every switch (does not re-center/resize to each image).
- [ ] **[ADMIN: NO]** (L707) **Pin, close and reopen** — With the window pinned at a known place/size, close Peek (`Esc`) and reopen it on the same selection. Confirm the new window opens at the **same pinned place and size**.
- [ ] **[ADMIN: NO]** (L708) **Unpin, switch file** — From the pinned window, **unpin**, then switch to a different file. Confirm the window **moves to the default placement** (leaves the pinned coordinates and re-centers/sizes to the new file).
- [ ] **[ADMIN: NO]** (L709) **Unpin, close and reopen** — With the window unpinned, close (`Esc`) and reopen Peek. Confirm the new window opens at the **default place/size**, not the previously-pinned coordinates.
> **L712L713 open the file in its default app** (the "Open with <default app>" action).
- [ ] **[ADMIN: NO]** (L712) **By clicking a button** — With a file peeked, click the **"Open with <default app>"** button. Confirm the file opens in its default application (a new app process/window for that file appears; match by the file's window title).
- [ ] **[ADMIN: NO]** (L713) **By pressing Enter** — With the Peek window focused, press **Enter**. Confirm the file opens in its default application (same result as L712).
- [ ] **[ADMIN: NO]** (L716) **Peek via command** — Run `PowerToys.Peek.UI.exe <file>`. Confirm it opens a `<file> - Peek` window previewing that file (the CLI accepts a file-path argument and shows the previewer).
- [ ] **[ADMIN: NO]** (L717) **Peek works while a session is already on** — Open several Peek sessions on different files, leaving prior windows open. Confirm each launch opens its own window and previews correctly with the others still open (no crash/hang, no fatal errors in `Peek\Logs`).
- [ ] **[ADMIN: NO]** (L719) **Folder navigation with Left/Right arrows** — With a **single** file selected in a folder, open Peek and press `RightArrow` / `LeftArrow`. Confirm you can cycle through **all files in the folder** (in order, wrapping at the ends).
- [ ] **[ADMIN: NO]** (L720) **Selection-scoped navigation** — Select **multiple** files (a subset of a folder), open Peek and press `RightArrow` / `LeftArrow`. Confirm navigation cycles **only among the selected files** and never escapes into the folder's other files.
- [ ] **[ADMIN: NO]** (L721) **Change the activation shortcut** — In Settings → Peek, change the activation shortcut (e.g. `Ctrl+Space``Ctrl+Shift+Space`). After the change takes effect (the runner re-registers the hook — it is **not** hot-reloaded), confirm the **new** chord opens Peek and the **old** chord no longer does. Restore the original shortcut afterward.

View File

@@ -1,44 +0,0 @@
# 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 an admin-requirement tag:
**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)
---
## PowerRename (17 items)
- [ ] **[ADMIN: NO]** (L393) Toggle the PowerRename enable switch **off**, then **on**, in PowerToys Settings. Confirm the `Rename with PowerRename` context-menu entry **disappears** when the module is disabled and **reappears** when it is enabled. (On Win11) With the module enabled, confirm `Rename with PowerRename` is present in **both** the Win11 tier-1 (modern) context menu **and** the classic "Show more options" (`#32768`) context menu.
- [ ] **[ADMIN: NO]** (L394) **Shell integration — verify the `Show PowerRename in` dropdown together with the `Hide icon in context menu` checkbox across all 4 combinations.** Both live under Settings → PowerRename → *Shell integration*; **expand the "Show PowerRename in" row** (the ˅ at the far right, not the dropdown arrow) to reveal the "Hide icon in context menu" checkbox. The icon setting applies to **both** the modern (Win11 tier-1) and legacy/extended menus. Verify all four:
- **`Default and extended context menu` + `Hide icon` unchecked** → `Rename with PowerRename` appears in the **default (modern) right-click menu, with** its icon.
- **`Default and extended context menu` + `Hide icon` checked** → appears in the default menu, **without** an icon.
- **`Extended context menu only` + `Hide icon` unchecked** → **absent from the default menu**; appears **only under Shift + right-click, with** its icon.
- **`Extended context menu only` + `Hide icon` checked** → absent from the default menu; appears **only under Shift + right-click, without** an icon.
- [ ] **[ADMIN: NO]** (L396) Toggle **autocomplete** (Settings → PowerRename → *Enable auto-complete for the search & replace fields*; settings key `MRUEnabled`, read at PR launch). With it **on** and prior search history present, launch PowerRename, focus the **Search** field and type a prefix (e.g. `p`) → a `SuggestionsPopup`/`SuggestionsList` of matching prior terms **appears**. With it **off** (relaunch), typing the same prefix shows **no** suggestions popup.
- [ ] **[ADMIN: NO]** (L397) Toggle **Show recently used strings** (settings key `PersistState`, read at PR launch). With it **on**: run PowerRename once with Search=`persist`, Replace=`KEPT`, and Apply; relaunch on a fresh file → the Search/Replace fields are **pre-populated** with `persist`/`KEPT` (restored from `power-rename-last-run-data.json`) and the preview auto-applies. With it **off**: every launch starts with **empty** Search/Replace fields.
- [ ] **[ADMIN: NO]** (L399) With `Hi World.txt` selected and a Replace producing `Hi World`, click each case toggle in turn and confirm the preview **Renamed** column: **Uppercase**`HI WORLD.TXT`, **Titlecase**`Hi World.txt`, **Lowercase**`hi world.txt`. Confirm the buttons are **mutually exclusive** — selecting one **deselects** the previously active case button (only one active at a time).
- [ ] **[ADMIN: NO]** (L400) Selection = one file (`aaa_file.txt`) + one folder (`aaa_folder` containing `aaa_inner.txt`); Search `aaa` → Replace `zzz`. Toggle **Include Files**, **Include Folders**, and **Include Subfolder Items** independently and confirm each gates only its row type in the preview:
- **All include on** → all three renamed (`zzz_file.txt`, `zzz_folder`, `zzz_inner.txt`).
- **Files off** → files' Renamed column empty; only `aaa_folder → zzz_folder`.
- **Folders off** → folder unchanged; both files renamed.
- **Subfolder Items off** → inner file excluded; top-level file + folder renamed.
Confirm the toggles **combine** (several can be off simultaneously).
- [ ] **[ADMIN: NO]** (L401) File `aaa.aaa`, Search `aaa` → Replace `zzz`, Match-all on. Using the **Apply to** selector (single-select), confirm each scope changes only the targeted part of the name: **Filename + extension** (default) → `aaa.aaa → zzz.zzz`; **Filename only**`aaa.aaa → zzz.aaa`; **Extension only**`aaa.aaa → aaa.zzz`. Only **one** option can be selected at a time.
- [ ] **[ADMIN: NO]** (L402) Enumerate Items. Test advanced enumeration using different values for every field `${start=10,increment=2,padding=4}`. With Enumerate items on and Replace = `item_${start=10,increment=2,padding=4}` applied to a 5-file selection, the preview **Renamed** column must show `item_0010`, `item_0012`, `item_0014`, `item_0016`, `item_0018` (counter = `start + index*increment` for 0-based index, zero-padded to `padding` digits; verify each field independently: `start=10` → first value 0010 not 0, `increment=2` → step of 2 not 1, `padding=4` → 4-digit zero-pad not unpadded).
- [ ] **[ADMIN: NO]** (L403) File `MixedCase.txt`, Search `mixed` → Replace `XXX`. With **Case sensitive OFF** (default) → `mixed` matches `Mixed`, preview `MixedCase.txt → XXXCase.txt`. With **Case sensitive ON** → no match: the Renamed column is **empty** and the **Apply** button is **disabled**.
- [ ] **[ADMIN: NO]** (L404) File `Foo_A_A_A.txt`, Search `A` → Replace `B`. With **Match all occurrences unchecked** (default) → only the **first** (left-to-right) match is replaced: `Foo_A_A_A.txt → Foo_B_A_A.txt`. With it **checked****all** occurrences are replaced: `Foo_A_A_A.txt → Foo_B_B_B.txt`.
- [ ] **[ADMIN: NO]** (L406) Files `IMG_001.png`..`IMG_003.png`. Enable **Use regular expressions** and set Search = `(.*).png`. Confirm the regex matches every filename (capture group populated) — with Replace = `foo_$1.png` the preview shows `IMG_00n.png → foo_IMG_00n.png` for n=1..3.
- [ ] **[ADMIN: NO]** (L407) Regex on, Search = `(.*).png`, Replace = `foo_$1.png` on files `IMG_001.png`..`IMG_003.png`. Confirm the capture-group back-reference `$1` is substituted with the matched stem: `IMG_001.png → foo_IMG_001.png`, `…002 → foo_IMG_002.png`, `…003 → foo_IMG_003.png` (with regex off, `$1` would appear literally).
- [ ] **[ADMIN: NO]** (L408) File `orig.txt` with a known creation time (e.g. `07/02/2026 11:54:54`). With regex on, Search = `.*\.txt$`, Replace = `$YYYY_$MMMM_$DD__$hh-$mm-$ss.txt`. Confirm each token expands from the file's creation date/time so the preview exactly matches the expected string `2026_July_02__11-54-54.txt` (`$YYYY``2026`, `$MMMM``July`, `$DD``02`, `$hh``11`, `$mm``54`, `$ss``54`; also verify `$fff` milliseconds).
- [ ] **[ADMIN: NO]** (L409) Enable **Use Boost library** (settings key `UseBoostLib`, read at PR launch — relaunch after toggling). Files `test.txt` and `nest.txt`; with regex on, Search = `(?<=t)est` (Perl lookbehind, unsupported by the default std::regex engine), Replace = `X`. Confirm the lookbehind evaluates without error and matches **only** where `est` is preceded by `t`: `test.txt → tX.txt`, while `nest.txt` is **unchanged**.
- [ ] **[ADMIN: NO]** (L411) Files `file1.txt, file2.txt, file3.txt`; Search `file` → Replace `done`. Uncheck the `file2.txt` row in the preview (row shows `[ ]`), then **Apply**. Confirm on disk that only the checked rows were renamed: `done1.txt, done3.txt, file2.txt` — the unchecked file is **left untouched**.
- [ ] **[ADMIN: NO]** (L412) Files `match1.txt, match2.txt, keepme.txt`; Search `match` → Replace `X` (`keepme.txt` does not match). Use the **Filter** (funnel) button above the file list: choosing **"Only show files that will be renamed"** hides the non-matching `keepme.txt` (only `match1.txt`, `match2.txt` visible); choosing **"Show all files"** restores all 3 rows.
- [ ] **[ADMIN: NO]** (L413) With a 3-file list all initially checked, use the **Select/deselect all** checkbox (header "Select or deselect all") above the file list: one click **deselects all** (all rows `[ ]`); clicking again **selects all** (all rows `[x]`) — toggling every row in a single action.

View File

@@ -1,159 +0,0 @@
# 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 `![alt](path)` 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 #8, 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 #12) wasn't cross-linked from §2.A. | `SKILL-UNCLEAR` | HIGH | ~12 min · 4 attempts | Add an explicit "shell-ext modules → see pitfall #12" pointer inside §2.A. |
```

View File

@@ -1,80 +0,0 @@
# Scenario router
This skill runs in **two scenarios**. They share ~80% of the machinery (the `winapp ui` drive
techniques in `../winapp-ui-testing.md`, the helper scripts, the per-module profiles in `../modules/`,
the classification taxonomy, state hygiene, and the report format). They differ on only a couple of
axes. **Pick the scenario first, read its doc, then run the shared engine in `SKILL.md`.**
| Axis | A — Module checklist | B — PR validation |
|---|---|---|
| **Checklist source** | **Supplied** file (`../release-checklist/<module>.md`) | **Derived** by the agent from each PR's description + diff |
| **Scope** | 1 module, exhaustive (e.g. 88 items) | 1 PR (deep) — or a release/hotfix set, one folder per PR |
| **Bits under test** | Installed shipped artifact (READ-ONLY) | **Depends on a sub-decision** — see below |
| **Discipline** | Mutate only via user-facing UI; restore in `finally{}` | Installed-path: same as A · Build-path: build & sideload unreleased bits, then restore |
| If the task is… | Scenario | Read |
|---|---|---|
| "verify all `<Module>` checklist items", "sign off Color Picker", a supplied checklist file | **A** | [`module-checklist.md`](./module-checklist.md) |
| "validate PR #N" (open **or** merged), "review this fix before merge", "build it and test the fix", "verify the PRs in this release / hotfix / milestone", "sign off 0.X.Y" | **B** | [`pr-validation.md`](./pr-validation.md) |
**PR validation is one scenario** whether the PR is open, merged, or part of a release set — the hard
part (deriving drivable claims from the PR) is identical. What varies is only *which bits you run*,
resolved by the sub-decision below.
---
## The one contract that differs — declare `BITS` first
The only real conflict is **what "the bits under test" are** and therefore what you're allowed to
touch. Resolve it explicitly at the start of every run and **echo it in the report header** so a
reviewer can trust the evidence chain.
**Scenario A** — always the installed artifact:
```
BITS: installed shipped artifact <version> (read-only)
```
**Scenario B** — the sub-decision is **"is the PR's code already in the build under test?"**
```
BITS: installed shipped artifact <version> (read-only) # yes — merged AND shipped in the build
BITS: local build of <Module> @ <sha/branch>, sideloaded # no — unmerged, or merged-but-unreleased
```
> **"Merged" is not the deciding word — "in the build under test" is.** A PR merged into `main` but
> not yet in the installed build is still the **build + sideload** case (e.g. #45242).
- **Installed / read-only (A, and B when the code is already shipped)** — the artifact is immutable.
Forbidden: copying source-built files into the install or `%LOCALAPPDATA%\...\PowerToys\...`,
pre-seeding caches the app/installer owns, editing module `settings.json` to bypass a Settings-UI
step, registering/unregistering COM/MSIX, killing helper processes except as a documented user
action. Allowed: anything a real user does through the shipped UI (toggles, hotkeys, set-value into
Settings fields), read-only probes, screenshots — always capture pre-state and restore in
`finally{}`. If the documented user flow does not produce the claimed outcome, that is **FAIL**
do not "rescue" it by editing install state.
- **Build + sideload (B when the code isn't in the build)** — testing unreleased code is the point,
so the immutability rule does **not** apply to *your* build. It still applies to *unrelated*
installed bits you didn't build. Restore the machine to the shipped build when done (see
`pr-validation.md` Step 4b).
> A run that mutates the wrong `BITS` (sideloads when the code is already installed, or drives the
> stale installed binary when validating unreleased code) is **invalid regardless of the verdict.**
> Set `BITS` before the first drive command.
---
## Verdict vocabulary (one taxonomy, two label sets)
The shared engine in `SKILL.md` Step 3 uses **PASS / FAIL(product|checklist) / BLOCKED(reason)**.
Older PR-validation reports sometimes use **PASS / FAIL / Don't-know-what-to-test / Incapable**. They
map 1:1 — use the `SKILL.md` set and treat the legacy labels as aliases:
| Engine (`SKILL.md` §3) | Legacy alias | Meaning |
|---|---|---|
| PASS | PASS | Drove the behavior; matches the claim. |
| FAIL (cause=product) | FAIL | Shipped/built behavior contradicts the claim → file a bug. |
| FAIL (cause=checklist) | Don't know what to test | The item/spec is too vague or stale to judge → fix the checklist, quote the ambiguity. |
| BLOCKED (`BLK-*`) | Incapable of Testing | Couldn't run the check after ≥2 entry-paths → name the concrete obstacle. |
Whichever label set the caller asks for, keep the **evidence rules identical**: no "source verified /
live deferred / pre-existing / probably / unlikely" weasel-words to justify a PASS — those flip the
verdict to FAIL or BLOCKED-with-a-named-environmental-reason.

View File

@@ -1,42 +0,0 @@
# Scenario A — Single-module release checklist
**Use when:** the task supplies (or points at) a module's checklist and asks you to verify every
item — e.g. "verify all 18 Color Picker items", "sign off Command Palette's 88 items".
This is the skill's original scenario; `SKILL.md` Steps 17 are written for it, so this doc is
short — it only nails down the inputs and the report shape.
## Bits under test
```
BITS: installed shipped artifact <version> (read-only)
```
Installed artifact is immutable; mutate only through the shipped UI and restore in `finally{}`
(see `index.md` → "bits contract" and `../pre-flight.md` §Hard rules).
## Inputs
| Input | Source |
|---|---|
| Checklist (the set of items) | `../release-checklist/<module>.md`**this file IS the items to verify.** Each item carries `[ADMIN: …]` + `[CLARITY: …]` metadata. See `../release-checklist/index.md` for the full module list. |
| Per-module recipes | `../modules/<module>.md` if it exists (entry-paths, item recipes, BLOCKED traps, fixtures). **Check this first.** If absent, fall back to the `SKILL.md` §2 drive-stack and author one afterwards (template in `../modules/README.md`). |
## Run order
1. `../pre-flight.md` — pre-flight + bootstrap (`SKILL.md` Step 1).
2. For each checklist item: pick a bucket from the verb (`SKILL.md` §2.A/§2.B/§2.C), drive it, then
classify (`SKILL.md` §3). One verdict + evidence per item.
3. `../reporting-format.md` — per-item table + top-of-report summary + §G retrospective.
4. `../pre-flight.md` §State hygiene + §Final wrap-up.
5. `SKILL.md` Step 7 — archive the workspace to the sign-off folder.
## Scope rule
**One module per run.** Never chain multiple modules into one report. (For "verify a whole
release", that's **Scenario B — PR validation**, which fans out across the release's PRs with per-PR folders.)
## Report
Use `../reporting-format.md` verbatim. Header must include the `BITS:` line and the module's total
item count `<N>`.

View File

@@ -1,210 +0,0 @@
# Scenario B — PR validation
**Use when:** you're asked to validate **one or more PRs** by deriving each PR's checklist from its
own description + diff, then driving it. This one scenario covers all the PR shapes:
- a single **open / unmerged** PR ("validate PR #N", "review this fix before merge"),
- a single **merged** PR ("check that #N actually works"),
- a whole **release / hotfix set** of merged PRs ("sign off 0.100.1", "verify the 14 PRs in this draft release").
The *hard, interesting* part — turning a PR into concrete, drivable claims — is identical for all of
them. They differ on only one thing: **what bits you run.**
---
## The bits sub-decision (the only real fork)
Ask one question up front and **echo the answer in the report header**:
> **Is the PR's code already in the build under test?**
| Answer | Bits under test | `BITS:` line |
|---|---|---|
| **Yes** — merged **and** present in the installed / shipped build you're testing | Drive the **installed** bits, **read-only** | `BITS: installed shipped artifact <version> (read-only)` |
| **No** — unmerged/open, **or** merged-but-not-yet-released (not in any shipped build) | **Build** the affected module + **sideload** it | `BITS: local build of <Module> @ <sha/branch>, sideloaded` |
> **"Merged" is not the deciding word — "in the build under test" is.** A PR that merged into `main`
> but hasn't shipped in the installed build is still the **build + sideload** case (e.g. validating
> a fix ahead of the next release — PR #45242 was exactly this). Only drive the installed bits when
> the code you're validating is genuinely in them.
- **Installed path — the artifact is immutable.** Forbidden: copying source-built files into the
install or `%LOCALAPPDATA%\...\PowerToys\...`, editing module `settings.json` to bypass a
Settings-UI step, registering/unregistering COM/MSIX, killing helpers except as a documented user
action. Allowed: anything a real user does through the shipped UI, read-only probes, screenshots —
capture pre-state and restore in `finally{}`. If the documented user flow doesn't produce the
claimed outcome, that's **FAIL** — never "rescue" it by editing install state.
- **Build + sideload path — building unreleased code is the whole point,** so the immutability rule
does **not** apply to *your* build. It still applies to *unrelated* installed bits you didn't
build. **Restore the machine to the shipped build when done** (Step 4b).
> A run that mutates the wrong `BITS` (sideloads when the code is already installed, or drives the
> stale installed binary when validating unreleased code) is **invalid regardless of the verdict.**
> Set `BITS` before the first drive command. Full forbidden/allowed list: `index.md` → bits contract
> and `../pre-flight.md` §Hard rules.
---
## Step 0 — Acquire the PR set (discovery model)
For a **single PR**, `N = 1` — skip straight to Step 1. For a **release / hotfix**, a full release
can carry ~100 PRs and a hotfix ~1025; blindly looping 100 is the wrong default. Resolve the set
with the **smallest, most explicit source available**, then apply the size gate.
### Input modes (prefer the most explicit one the caller gave you)
| Mode | Source | How to enumerate |
|---|---|---|
| **1. Explicit set** *(preferred)* | PR numbers, a sign-off doc, or a draft/published release whose notes list the PRs | Parse the PR numbers directly. For a release: `gh api repos/microsoft/PowerToys/releases/<id>` → extract `#NNNNN` from `.body`. |
| **2. Discoverable source** | A milestone, a release tag, or a commit/tag range | `gh pr list --repo microsoft/PowerToys --search "milestone:0.X" --state merged --limit 200 --json number,title,labels,files` · or for a range: `git log <tagA>..<tagB> --oneline` then extract `(#NNNNN)` merge refs. |
| **3. Caller defers entirely** | "verify the latest release" with no list | Resolve to a concrete release/milestone first (mode 1/2). If you cannot, **ask** rather than guess. |
### Size gate (the guardrail)
Let `N` = enumerated candidate count, `MAX_AUTO = 25` (hotfix-sized; tune per request).
- **`N ≤ MAX_AUTO`** → proceed: derive + verify each PR (this is the single-PR and hotfix case).
- **`N > MAX_AUTO`** → **STOP. Do not blind-loop.** Present the candidate list grouped by
module/area with counts, and require the caller to **scope** before deep-verifying: by module, by
label (e.g. `Needs-Verification`, priority), or an explicit subset. Confirm, then verify the scoped
set. **Depth beats throughput** — deeply verifying 20 PRs and leaving a clear queue of the rest
beats 100 shallow "source-verified" reports.
### Pre-filter non-runtime PRs
Before counting against the gate, set aside PRs with **no user-observable runtime surface** — list
them as `exempt` (not BLOCKED, not PASS) with the reason, don't spend drive cycles on them:
docs-only, CI/pipeline/build-only, dependency bumps, localization/translation-only,
release-signing/installer-plumbing with nothing to click. Detect via labels and changed-file paths
(`gh pr view N --json files,labels`). Everything else (anything a user opens/toggles/presses/types/
previews) goes into the deep-verify set and is subject to the live-drive floor below.
---
## Step 1 — Per PR: derive the checklist
For each PR in the (possibly scoped) set:
```powershell
gh pr view <N> --repo microsoft/PowerToys --json title,body,files,labels,state,mergedAt
gh pr diff <N> --repo microsoft/PowerToys # read the diff — it tells you what actually changed
```
Turn the description + diff into **13 concrete checklist items** (the observable claim(s) the PR
makes), then drive each with the `SKILL.md` §2 bucket selector and classify with `SKILL.md` §3. From
`--json files`, also identify the **affected project(s)** (`.csproj` / `.vcxproj`) — you'll need
them for the build path (Step 2b). If, after the description AND the diff, you still cannot tell what
to verify → **FAIL (cause=checklist)** ("Don't know what to test"), quoting the ambiguity — do not
guess.
You may `grep`/`view` a **read-only** local clone/worktree for source context (XAML AutomationIds,
the `.cs`/`.cpp` the PR touched). On the **installed path** you must not run that code against the
install; on the **build + sideload path**, building it is exactly the point (Step 2b).
### Live-drive floor (anti-shallow-verification)
If the PR has a verb a real user performs (open/toggle/press/drag/right-click/type/preview/paste/
invoke/install/pin/search/record/scroll), the steps table MUST contain **≥4 `winapp ui …` rows** and
**≥1 `winapp ui screenshot` of the post-state**. A PR with a user-visible surface and zero
`winapp ui …` rows is **not validated**. "Source verified; live deferred" is a weasel-word that
downgrades the verdict (see `index.md` → verdict vocabulary).
---
## Step 2 — Get the bits under test
Follow **2a or 2b** per the bits sub-decision above.
### 2a — Installed path (code already in the build under test)
Nothing to deploy: the shipped runner/modules are already installed. Confirm the module is present
(`../pre-flight.md`), then drive the **installed** bits read-only. This is the path for a merged PR
that has shipped, and for a release/hotfix sign-off where you're validating the released artifact.
### 2b — Build + sideload path (code not in the build under test)
Building & deploying unreleased code is the point. Build **only the affected project** (identified in
Step 1) — do **not** build the whole solution just to populate every module.
```powershell
cd <PT_REPO>
gh pr checkout <N> # PR branch locally (open PRs); for a merged-unreleased PR, check out its merge commit / the branch
tools\build\New-WorktreeFromBranch.ps1 -Branch <pr-branch> # isolated worktree
git submodule update --init --recursive # once
tools\build\build-essentials.cmd # first build / NuGet restore (runner + settings only)
tools\build\build.ps1 -Platform x64 -Configuration Release # run from the changed .csproj/.vcxproj dir
```
**Exit code 0 = success (absolute).** On non-zero, read `build.<config>.<platform>.errors.log` next
to the project. If the toolchain is missing (VS 2022 17.4+/2026, Windows SDK) and the build can't
complete → **BLOCKED (`BLK-ENV`)** — never PASS on an unbuilt PR.
> **Partial build ⇒ missing-module dialogs are EXPECTED.** A module-specific build produces only
> *your* module, so the build-output runner pops a modal **"Failed to load PowerToys.<Module>ModuleInterface.dll"**
> (`#32770`) for each un-built module. This is normal for a targeted build, **not** a build failure.
**Deploy — run the freshly built bits, don't overlay onto the install:**
- **Unpackaged module** (FancyZones, PT Run, ColorPicker, Peek, KBM, Advanced Paste, …): stop the
installed runner, start the **build-output** runner, and dismiss the expected dialogs.
```powershell
Get-Process PowerToys -EA SilentlyContinue | ForEach-Object { Stop-Process -Id $_.Id -Force }
Start-Process "<PT_REPO>\x64\Release\PowerToys.exe"
(Get-Process PowerToys | Select-Object -First 1).Path # must point under the build output
```
The "Failed to load …" boxes are native `#32770` message boxes — `winapp ui invoke OK` is
unreliable; `PostMessage`/`SendMessage` `WM_CLOSE (0x0010)` to each, or send Enter to the focused
dialog, until none remain. (Only if dialogs keep racing your module → full solution build as a
fallback.) **Never** overlay a partial build onto the installed layout — mixing `0.0.1` files with
the shipped `0.100.x` install (esp. a WinUI module's `.dll` without its `.pri`) silently corrupts
the test.
- **Packaged module** (Command Palette / CmdPal — MSIX): enable Developer Mode, remove the shipped
package, `Add-AppxPackage -Register "<build-output>\AppxManifest.xml"`, and restore on cleanup.
**Prove it's your bits** before driving: the running module's path is under the build output, and/or
a dev version string (e.g. Settings shows `v0.0.1`, not `0.100.x`). A run that accidentally drove the
installed binary is invalid.
Because you control the build, you can make a PASS decisive rather than "the fix is present" — e.g.
exercise the **failing** state the PR fixes (revert-and-rebuild, or compare against the installed
shipped build).
---
## Step 3 — Drive + classify
Same engine as everything else: per item pick the `SKILL.md` §2 bucket, drive, classify with
`SKILL.md` §3. The live-drive floor (Step 1) applies.
## Step 4 — Artifacts, report, restore
**Artifacts + report.** One folder per PR: `{Module}-PR{Number}/` (e.g. `CmdPal-PR48689/`,
`AdvancedPaste-PR45242/`). All screenshots and the PR's `report.md` go there; the verdict lives
**inside** `report.md`, not in the folder name. Use the `../reporting-format.md` per-item table; the
`winapp invoke` column is a hard contract — a literal `winapp ui …` command or `` (never a
`Select-String`/`gh`/`Test-Path` there). The header carries the `BITS:` line (incl. the sha/branch +
proof-of-your-bits path on the sideload path) and, on 2b, a build summary (exit code, project built).
**Roll-up** (multi-PR sets): top-level summary table — PR · Module · verdict · one-line evidence —
plus the `exempt` list and (if `N > MAX_AUTO`) the un-scoped **queue** of PRs not yet verified.
Include a §G retrospective (run friction).
**Restore (Step 4b / sideload path only).** Stop your built runner and bring the shipped build back:
```powershell
Get-Process PowerToys -EA SilentlyContinue | ForEach-Object { Stop-Process -Id $_.Id -Force }
Start-Process "$env:LOCALAPPDATA\PowerToys\PowerToys.exe" # or the Program Files install
# Packaged: re-add the shipped package (Add-AppxPackage -Register the shipped AppxManifest.xml)
```
Restore all mutated state, confirm the runner is healthy, and disclose any residue in the report.
---
## Worked references
- **Single unmerged/unreleased PR, build + sideload:** PR #45242 (Advanced Paste "Show AI paste
section") — derived 3 claims from the diff, built only the AdvancedPaste project, sideloaded the
dev `v0.0.1` runner (dismissed the partial-build dialogs), drove Settings + the AP window + a GPO
gate, then restored the shipped build. 3/3 PASS.
- **Release/hotfix set, installed bits:** the 0.100.1 14-PR sign-off (8 PASS / 6 BLOCKED) — derived
each PR's checklist from its release-notes line + diff, drove the installed bits, and blocked the
hardware/visual-only items (DDC/CI, dual-GPU, audio, capture-excluded overlays) with named reasons.

View File

@@ -1,541 +0,0 @@
# 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-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 |
### Selecting a ComboBox / dropdown item
*Reading* a ComboBox's current value is one step (`wait-for "Cmb" --value "Dark"`); **changing** the selection takes three, because a collapsed ComboBox's options are not in the UIA tree until it is expanded:
1. **Expand** - `winapp ui invoke <cmb-id>` (fires ExpandCollapsePattern; the dropdown opens).
2. **Inspect while open** - the options now appear as `ListItem`s in a popup with their **own ids** (e.g. `itm-<option>-<suffix>`), distinct from the combo's id. Resolve the target: `winapp ui search '<option text>' -w <hwnd> --json` and pick the match with `type -eq 'ListItem'`.
3. **Invoke that ListItem id** - `winapp ui invoke <itm-id>` (fires SelectionItemPattern). Verify with `wait-for <cmb-id> --value "<option>"`.
**Trap:** after expanding, do **not** invoke the option by its caption text - the text match lands on the child `Text` label, not the selectable `ListItem`, and silently no-ops. Always resolve and invoke the `itm-...` id, not the caption and not the collapsed combo id.
### 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).

View File

@@ -1,76 +0,0 @@
# 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)
}
}

View File

@@ -1,61 +0,0 @@
# 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)
}

View File

@@ -1,99 +0,0 @@
# 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
}

View File

@@ -1,136 +0,0 @@
# 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 }
}

View File

@@ -1,439 +0,0 @@
# 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("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls, string win);
[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);
[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte s, uint f, IntPtr e);
public const uint RIGHTDOWN=0x0008, RIGHTUP=0x0010, LEFTDOWN=0x0002, LEFTUP=0x0004, KEYUP=0x0002;
// Shift+F10 = "open context menu for the current selection/focus". With NOTHING selected it opens the
// folder-BACKGROUND menu - coordinate-free, so it can't miss a row or land in the preview pane.
public static void ShiftF10() {
keybd_event(0x10,0,0,IntPtr.Zero); System.Threading.Thread.Sleep(40);
keybd_event(0x79,0,0,IntPtr.Zero); System.Threading.Thread.Sleep(60); keybd_event(0x79,0,KEYUP,IntPtr.Zero);
System.Threading.Thread.Sleep(40); keybd_event(0x10,0,KEYUP,IntPtr.Zero);
}
public static void EscKey() { keybd_event(0x1B,0,0,IntPtr.Zero); System.Threading.Thread.Sleep(40); keybd_event(0x1B,0,KEYUP,IntPtr.Zero); }
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.' }
# Turn off the preview pane (best-effort) so a fallback coordinate right-click can't land in the
# preview surface -> folder-BACKGROUND menu (rejected by the honesty guard below).
try { Disable-PtPreviewPane -ExplorerHwnd $ExplorerHwnd | Out-Null } catch { }
$fgEverOk = $false
for ($try = 1; $try -le $MaxTries; $try++) {
# Bring Explorer to the foreground AND confirm it actually stuck. Shift+F10 and the coordinate
# fallback both need the target window to OWN the foreground + keyboard focus. If the input
# desktop is detached (RDP minimized/disconnected, workstation locked -> GetForegroundWindow()=0)
# or another process holds the foreground-lock, ForceForeground silently no-ops, the keystrokes
# go nowhere, and the menu never opens. Track whether foreground ever stuck so the throw below
# can distinguish this environment problem from a genuine wrong-menu open.
$fgThisTry = $false
for ($f = 0; $f -lt 3; $f++) {
[PtCtx]::ForceForeground([IntPtr]$ExplorerHwnd); Start-Sleep -Milliseconds 400
if ([PtCtx]::GetForegroundWindow() -eq [IntPtr]$ExplorerHwnd) { $fgThisTry = $true; $fgEverOk = $true; break }
}
# HARD GATE: Explorer must actually OWN the foreground before we synthesize input. If it never
# stuck (locked/secure desktop, or another window holds the foreground-lock), the Shift+F10 /
# coordinate-click below would go nowhere - so skip them and retry rather than waste keystrokes.
# If it never sticks across all $MaxTries, $fgEverOk stays $false and we throw BLK-ENV (below).
if (-not $fgThisTry) { Start-Sleep -Milliseconds 500; continue }
# Select the target via Shell COM first (reliable, coordinate-free, scrolls it into view) so the
# right-click has a guaranteed-visible selected row - never opens the file / never renames it.
try {
$sh = New-Object -ComObject Shell.Application
$w = $sh.Windows() | Where-Object { $_.HWND -eq $ExplorerHwnd } | Select-Object -First 1
if ($w -and $w.Document) {
$it = $w.Document.Folder.Items() | Where-Object { $_.Name -eq $FileName -or $_.Name -like "$FileName*" } | Select-Object -First 1
if ($it) { $w.Document.SelectItem($it, 0x2D); Start-Sleep -Milliseconds 300 }
}
} catch { }
$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) { Start-Sleep -Milliseconds 500; continue } # transient (mid-populate) - retry
# OPEN the menu COORDINATE-FREE (preferred): Shift+F10 acts on the COM-selected item - no pixel
# involved, so a preview pane / DPI / wide Details row / screen-edge row can't misplace the click
# (measured 10/10 on fresh windows). First restore KEYBOARD focus to the list item so Shift+F10
# targets the file and not the nav tree / address bar (verified: focus-in-tree -> wrong menu).
winapp ui focus $item.selector -w $ExplorerHwnd 2>$null | Out-Null; Start-Sleep -Milliseconds 200
[PtCtx]::ShiftF10()
# Poll for the Shift+F10 menu (the PopupHost can take >1.2s to render under RDP / slow foreground)
# before degrading to the fragile coordinate fallback below. ~3.6s budget (12 x 300ms).
$menu = $null
for ($poll = 0; $poll -lt 12 -and -not $menu; $poll++) {
Start-Sleep -Milliseconds 300
$menu = winapp ui list-windows --json 2>$null | ConvertFrom-Json |
Where-Object { $_.className -match 'PopupWindowSiteBridge' } | Sort-Object height -Descending | Select-Object -First 1
}
if (-not $menu) { # Shift+F10 produced nothing - fall back to the coordinate right-click by selector
winapp ui click --right $item.selector -w $ExplorerHwnd 2>$null | Out-Null
Start-Sleep -Seconds 2
$menu = winapp ui list-windows --json 2>$null | ConvertFrom-Json |
Where-Object { $_.className -match 'PopupWindowSiteBridge' } | Sort-Object height -Descending | Select-Object -First 1
}
if (-not $menu) { # still nothing - last-resort coordinate right-click on the row point
[PtCtx]::RightClick([int]($item.x + [Math]::Min(80, $item.width/2)), [int]($item.y + $item.height/2))
Start-Sleep -Seconds 2
$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) {
# HONESTY GUARD: confirm we actually opened the FILE item's menu, not the folder background
# (winapp's click point can land in the preview pane / empty canvas -> background menu). A file
# menu has Open/Cut/Copy/Delete; the background menu has View/Sort by/Group by. Never return
# the background menu here and pretend it's the file's - retry, then fail loudly.
$names = Get-PtContextMenuItems -MenuHwnd $menu.hwnd
if ($names -match '^(Open|Cut|Copy|Delete|Rename)$') { return $menu.hwnd }
[PtCtx]::EscKey(); Start-Sleep -Milliseconds 400 # wrong (background) menu - close and retry
}
Start-Sleep -Milliseconds 500
}
if (-not $fgEverOk) {
throw "BLK-ENV: could not bring Explorer (HWND $ExplorerHwnd) to the foreground after $MaxTries attempts - GetForegroundWindow() never matched it. This is a detached/locked input desktop (RDP minimized or disconnected, workstation locked, screensaver) or another window holds the foreground-lock; Shift+F10 keystrokes are dropped in this state. Restore an attached interactive desktop and retry. See references/environment-setup.md and SKILL.md pitfall #7."
}
throw "Could not open the FILE context menu for '$FileName' after $MaxTries attempts (got background/none) even though Explorer had the foreground. If the module's entry appears on the folder menu (PowerRename, New+), use Open-PtBackgroundContextMenu instead."
}
# Opens the folder-BACKGROUND context menu (no file needed) via Shift+F10 with nothing selected -
# coordinate-free, so it can't miss a row or land in the preview pane. Use this for modules whose entry
# appears on the folder menu (PowerRename, New+). NOT valid for File Locksmith / Image Resizer, whose
# entries only appear on a selected file (Image Resizer: an image) - use Open-PtExplorerContextMenu +
# Select-PtExplorerFiles for those.
function Open-PtBackgroundContextMenu {
param([Parameter(Mandatory)][int]$ExplorerHwnd, [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 400
[PtCtx]::EscKey(); Start-Sleep -Milliseconds 200 # clear any selection so Shift+F10 targets the folder background
# Put KEYBOARD focus in the FILE-LIST (not the nav tree / address bar) so Shift+F10 opens the
# folder-background menu, not a tree node's menu (verified: focus-in-tree -> wrong menu, module entry
# absent). Do this LAST before Shift+F10. Coordinate-free via UIA SetFocus.
$listSel = (winapp ui search 'Items View' -w $ExplorerHwnd --json 2>$null | ConvertFrom-Json).matches |
Where-Object { $_.type -eq 'List' } | Select-Object -First 1 -ExpandProperty selector
if ($listSel) { winapp ui focus $listSel -w $ExplorerHwnd 2>$null | Out-Null; Start-Sleep -Milliseconds 200 }
[PtCtx]::ShiftF10()
# Poll for the background menu (PopupHost can take >2s to render under RDP / slow foreground). ~3.6s budget.
$menu = $null
for ($poll = 0; $poll -lt 12 -and -not $menu; $poll++) {
Start-Sleep -Milliseconds 300
$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) {
# Confirm it's the BACKGROUND menu (View/Sort by/Group by), not an item menu.
$names = Get-PtContextMenuItems -MenuHwnd $menu.hwnd
if ($names -match '^(View|Sort by|Group by)$') { return $menu.hwnd }
[PtCtx]::EscKey(); Start-Sleep -Milliseconds 400
}
Start-Sleep -Milliseconds 500
}
throw "Could not open the folder BACKGROUND context menu after $MaxTries 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 - works on ANY menu HWND: the modern Win11 menu (from
# Open-PtBackgroundContextMenu / Open-PtExplorerContextMenu) OR the legacy #32768 menu (from
# Open-PtShowMoreOptionsMenu). Use for present/absent assertions and to discover a module's caption.
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 }
}
# Open an Explorer window on $Path and return its CabinetWClass HWND (int) - the handle the context-menu
# openers need. Polls until the window appears (or throws). Use at the start of a synthetic-menu flow.
# Detect whether the Explorer preview (reading) pane is currently shown. Heuristic: when the preview
# pane is open it occupies the right portion of the window, so the "Shell Folder View" list pane's
# right edge falls well short of the window's right edge (>200px gap). Returns $true/$false; best-effort
# $false on any error. Uses UIA only (no foreground / keystrokes needed).
function Get-PtPreviewPaneOn {
param([Parameter(Mandatory)][int]$ExplorerHwnd)
try {
$win = winapp ui list-windows --json 2>$null | ConvertFrom-Json |
Where-Object { $_.hwnd -eq $ExplorerHwnd } | Select-Object -First 1
if (-not $win) { return $false }
$line = (winapp ui inspect -w $ExplorerHwnd --depth 10 2>$null | Out-String) -split "`r?`n" |
Where-Object { $_ -match 'Shell Folder View' } | Select-Object -First 1
if ($line -match '\((\d+),(\d+)\s+(\d+)x(\d+)\)') {
$right = [int]$matches[1] + [int]$matches[3]
return (($win.width - $right) -gt 200)
}
} catch { }
return $false
}
# Turn the Explorer preview (reading) pane OFF if it's on. An open preview pane is the top cause of the
# coordinate right-click FALLBACK (in Open-PtExplorerContextMenu, used when Shift+F10 doesn't produce a
# menu) landing in the preview surface -> the folder-BACKGROUND menu, which the honesty guard rejects
# (looks like "got background/none"). Toggles via the command-bar "PreviewPaneToggleButton" using UIA
# InvokePattern (no foreground needed, unlike the Alt+P keystroke). Best-effort: never throws, so it
# can't break the menu flow. Returns $true if the pane ends up OFF. Note: on a very narrow window the
# button can collapse into the "..." More menu and the invoke may miss - the width heuristic then simply
# reports the unchanged state and the caller proceeds (Shift+F10 is unaffected either way).
function Disable-PtPreviewPane {
param([Parameter(Mandatory)][int]$ExplorerHwnd)
for ($i = 0; $i -lt 2; $i++) {
if (-not (Get-PtPreviewPaneOn -ExplorerHwnd $ExplorerHwnd)) { return $true }
winapp ui invoke PreviewPaneToggleButton -w $ExplorerHwnd 2>$null | Out-Null
Start-Sleep -Milliseconds 900
}
return (-not (Get-PtPreviewPaneOn -ExplorerHwnd $ExplorerHwnd))
}
function Open-PtExplorerWindow {
param([Parameter(Mandatory)][string]$Path, [int]$TimeoutSec = 10)
if (-not (Test-Path $Path)) { throw "Path not found: $Path" }
Start-Process explorer.exe $Path
$leaf = Split-Path $Path -Leaf
$h = $null; $deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
Start-Sleep -Milliseconds 800
$h = (winapp ui list-windows --json 2>$null | ConvertFrom-Json |
Where-Object { $_.className -eq 'CabinetWClass' -and $_.title -match [regex]::Escape($leaf) } |
Select-Object -First 1).hwnd
} while (-not $h -and (Get-Date) -lt $deadline)
if (-not $h) { throw "Explorer window for '$Path' did not appear within $TimeoutSec s." }
# Turn off the preview pane so the coordinate right-click fallback in Open-PtExplorerContextMenu
# can't land in the preview surface (-> folder-background menu). Best-effort; ignore failures.
try { Disable-PtPreviewPane -ExplorerHwnd ([int]$h) | Out-Null } catch { }
[int]$h
}
# From an already-open MODERN menu (HWND from Open-PtBackgroundContextMenu / Open-PtExplorerContextMenu),
# expand "Show more options" and return the legacy classic #32768 menu's HWND (int). The #32768 window is
# NOT enumerable via `winapp ui list-windows`, so its handle is resolved with Win32 FindWindow; the returned
# HWND is a normal menu HWND - read it with Get-PtContextMenuItems / act with Invoke-PtContextMenuItem, exactly
# like the modern menu. See explorer-context-menu-flow.md for the writeup.
function Open-PtShowMoreOptionsMenu {
param([Parameter(Mandatory)][int]$MenuHwnd, [int]$TimeoutSec = 6)
if (-not (Invoke-PtContextMenuItem -MenuHwnd $MenuHwnd -ItemName 'Show more options')) {
throw "No 'Show more options' entry on the modern menu (HWND $MenuHwnd) - is this a Win11 packaged menu?"
}
$classic = [IntPtr]::Zero; $deadline = (Get-Date).AddSeconds($TimeoutSec)
do { Start-Sleep -Milliseconds 500; $classic = [PtCtx]::FindWindow('#32768', $null) }
while ($classic -eq [IntPtr]::Zero -and (Get-Date) -lt $deadline)
if ($classic -eq [IntPtr]::Zero) { throw 'Classic #32768 menu did not appear after invoking "Show more options".' }
[int64]$classic
}
# From an already-open MODERN menu (HWND from Open-PtBackgroundContextMenu / Open-PtExplorerContextMenu),
# expand a submenu-parent item (e.g. New+'s "New+") and return the child submenu's HWND (int). This is the
# MODERN-menu sibling of Open-PtShowMoreOptionsMenu: same "invoke a parent item -> wait -> return the child
# menu's HWND" shape, but the child is a WinUI PopupWindowSiteBridge popup (NOT the legacy #32768 window),
# so it's resolved by enumerating windows rather than FindWindow('#32768'). It reuses Invoke-PtContextMenuItem
# for the click and Get-PtContextMenuItems to disambiguate the popup; the returned HWND is a normal menu HWND
# you read/act on with Get-PtContextMenuItems / Invoke-PtContextMenuItem, exactly like the parent menu.
# -MenuHwnd the parent modern menu's HWND
# -ItemName the submenu-parent MenuItem to invoke (e.g. 'New+')
# -ContainsItem an item name known to live in the CHILD submenu (e.g. New+'s always-present 'Open templates'),
# used to pick the right popup when several PopupWindowSiteBridge windows are open (parent + child).
# Throws if the parent item is absent, or if no child popup containing -ContainsItem appears within TimeoutSec.
function Expand-PtModernSubmenu {
param(
[Parameter(Mandatory)][int]$MenuHwnd,
[Parameter(Mandatory)][string]$ItemName,
[string]$ContainsItem,
[int]$TimeoutSec = 6
)
# Record the popups already open so we can distinguish the NEW child popup from the parent menu.
$before = @(winapp ui list-windows --json 2>$null | ConvertFrom-Json |
Where-Object { $_.className -match 'PopupWindowSiteBridge' } | ForEach-Object { [int]$_.hwnd })
# Poll for the parent item before invoking: Win11 menus populate asynchronously and 3rd-party
# packaged entries (New+, PowerRename, ...) land a beat AFTER the base verbs, so a too-early invoke
# on a freshly-opened menu can miss the item (a transient race, esp. right after enabling the module).
$invoked = $false; $itemDeadline = (Get-Date).AddSeconds([Math]::Max(2, [Math]::Floor($TimeoutSec / 2)))
do {
if (Invoke-PtContextMenuItem -MenuHwnd $MenuHwnd -ItemName $ItemName) { $invoked = $true; break }
Start-Sleep -Milliseconds 400
} while ((Get-Date) -lt $itemDeadline)
if (-not $invoked) {
throw "No '$ItemName' entry on the modern menu (HWND $MenuHwnd) to expand (still absent after waiting for late-populating packaged entries)."
}
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
Start-Sleep -Milliseconds 300
$popups = winapp ui list-windows --json 2>$null | ConvertFrom-Json |
Where-Object { $_.className -match 'PopupWindowSiteBridge' }
# Prefer a popup that actually contains the known child item (unambiguous). Otherwise, take a
# newly-appeared popup (one not open before the invoke) as the child submenu.
if ($ContainsItem) {
foreach ($p in $popups) { if ((Get-PtContextMenuItems -MenuHwnd $p.hwnd) -contains $ContainsItem) { return [int]$p.hwnd } }
} else {
$new = $popups | Where-Object { [int]$_.hwnd -notin $before } | Sort-Object height -Descending | Select-Object -First 1
if ($new) { return [int]$new.hwnd }
}
} while ((Get-Date) -lt $deadline)
throw "Submenu for '$ItemName' did not appear within $TimeoutSec s (looked for a PopupWindowSiteBridge$(if($ContainsItem){" containing '$ContainsItem'"}))."
}
# Open the EXTENDED (CMF_EXTENDEDVERBS) legacy #32768 menu for a file via a GENUINE Shift+right-click, and
# return its HWND (int). Use this - NOT Open-PtShowMoreOptionsMenu - whenever the entry under test is registered
# "extended-verbs only" (e.g. PowerRename "Extended context menu only"): the non-Shift "Show more options" path
# does NOT pass CMF_EXTENDEDVERBS, so such entries are (correctly) absent there and only appear on Shift+right-click
# (src PowerRenameExt.cpp:84 returns E_FAIL unless CMF_EXTENDEDVERBS). Mechanics: COM-select the file (verifiable),
# hold VK_SHIFT around winapp's element-resolved right-click, release Shift, then FindWindow('#32768'). Retries
# because the first attempt right after Explorer opens can miss (foreground not settled). Read/act on the returned
# HWND with Get-PtContextMenuItems / Invoke-PtContextMenuItem like any other menu.
function Open-PtShiftRightClickMenu {
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
# COM-select the target first (deterministic, verifiable, scrolls it into view; never opens/renames it).
try {
$sh = New-Object -ComObject Shell.Application
$w = $sh.Windows() | Where-Object { $_.HWND -eq $ExplorerHwnd } | Select-Object -First 1
if ($w -and $w.Document) {
$it = $w.Document.Folder.Items() | Where-Object { $_.Name -eq $FileName -or $_.Name -like "$FileName*" } | Select-Object -First 1
if ($it) { $w.Document.SelectItem($it, 0x1D); Start-Sleep -Milliseconds 300 }
}
} catch { }
$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) { Start-Sleep -Milliseconds 500; continue } # transient (mid-populate) - retry
# Hold Shift around winapp's element-resolved right-click (Shift => CMF_EXTENDEDVERBS). Always release Shift.
try {
[PtCtx]::keybd_event(0x10, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60 # SHIFT down
winapp ui click --right $item.selector -w $ExplorerHwnd 2>$null | Out-Null
Start-Sleep -Milliseconds 80
} finally { [PtCtx]::keybd_event(0x10, 0, 0x2, [IntPtr]::Zero) } # SHIFT up
Start-Sleep -Milliseconds 800
# Shift+right-click on Win11 opens the classic #32768 DIRECTLY (bypasses the modern PopupWindowSiteBridge).
$classic = [int64][PtCtx]::FindWindow('#32768', $null)
if ($classic -ne 0) { return [int]$classic }
Start-Sleep -Milliseconds 500
}
throw "Could not open the extended (Shift+right-click) #32768 menu for '$FileName' after $MaxTries attempts."
}
# Enable/disable a whole PowerToys MODULE through the real Settings-UI toggle switch - the faithful
# user flow for "check enable/disable of the module works" items, and the sanctioned way to do it.
# The UI toggle exercises the Settings->runner IPC enable/disable path and takes effect on the live
# context menu WITHOUT a runner restart; for New+ it also runs the enable-time CopyTemplateExamples
# that seeds templates. If the desktop is locked so this can't be driven, mark the item BLK-ENV.
# Verified for PowerRename / File Locksmith / Image Resizer / New+ (2026-07-01). Always restore the
# original state in a finally{} (call again with the old value).
#
# $r = Set-PtModuleEnabledViaSettingsUI -PageTag PowerRename -Enabled $false -EnabledKey PowerRename
# # ...assert the context-menu entry is gone...
# Set-PtModuleEnabledViaSettingsUI -PageTag PowerRename -Enabled $true -EnabledKey PowerRename # restore
#
# -PageTag : the `--open-settings=<tag>` moniker / page id. Known: PowerRename, FileLocksmith,
# ImageResizer, NewPlus (no spaces).
# -Enabled : desired state ($true = on, $false = off). No-ops if already in that state.
# -EnabledKey : (optional) master settings.json `enabled.<key>` to cross-check. Note the key uses the
# DISPLAY name with spaces: 'PowerRename','File Locksmith','Image Resizer','NewPlus'.
# Returns: [pscustomobject] @{ SettingsHwnd; Selector; State('on'|'off'); EnabledFlag; Matched }.
# NOTE: leaves the Settings window OPEN (caller closes it, e.g. `winapp ui invoke btn-close-<id> -w <hwnd>`).
function Set-PtModuleEnabledViaSettingsUI {
param(
[Parameter(Mandatory)][string]$PageTag,
[Parameter(Mandatory)][bool]$Enabled,
[string]$EnabledKey,
[int]$TimeoutSec = 20
)
if (-not (Test-PtDesktopInteractive)) { throw 'BLK-ENV: desktop locked / no foreground; cannot drive the Settings UI toggle.' }
# 1) Open Settings straight on the module page (the runner honours --open-settings=<tag>; single-instance,
# so if Settings is already open it just navigates+focuses that window).
Start-Process "$env:LOCALAPPDATA\PowerToys\PowerToys.exe" -ArgumentList "--open-settings=$PageTag" -EA SilentlyContinue
$h = $null; $deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
Start-Sleep 1
$w = winapp ui list-windows --json 2>$null | ConvertFrom-Json |
Where-Object { $_.title -match 'PowerToys Settings' } | Select-Object -First 1
if ($w) { $h = [int]$w.hwnd }
} while (-not $h -and (Get-Date) -lt $deadline)
if (-not $h) { throw "Could not find the PowerToys Settings window after --open-settings=$PageTag." }
Start-Sleep 2 # let the page render
# 2) The module's master enable toggle is ALWAYS the top-most ToggleSwitch on its page, i.e. the FIRST
# element whose UIA state renders as [on]/[off]. AutomationIds carry a per-session suffix
# (btn-powerrename-XXXX), so discover by state, never by a hard-coded id.
$sel = $null; $state = $null; $deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$dump = (winapp ui inspect -w $h --depth 12 2>$null | Out-String) -split "`r?`n"
$line = $dump | Where-Object { $_ -match '\[(on|off)\]' } | Select-Object -First 1
if ($line -and $line -match '^\s*(\S+)\b.*\[(on|off)\]') { $sel = $matches[1]; $state = $matches[2]; break }
Start-Sleep 1
} while ((Get-Date) -lt $deadline)
if (-not $sel) { throw "Could not locate the enable ToggleSwitch on the '$PageTag' Settings page." }
# 3) Flip only if needed
$want = if ($Enabled) { 'on' } else { 'off' }
if ($state -ne $want) {
winapp ui invoke $sel -w $h 2>$null | Out-Null
Start-Sleep 3
$dump = (winapp ui inspect -w $h --depth 12 2>$null | Out-String) -split "`r?`n"
$line = $dump | Where-Object { $_ -match [regex]::Escape($sel) -and $_ -match '\[(on|off)\]' } | Select-Object -First 1
if ($line -and $line -match '\[(on|off)\]') { $state = $matches[1] }
}
# 4) Optional cross-check against the master settings.json enabled flag (the UI writes it for you)
$flag = $null
if ($EnabledKey) {
Start-Sleep 1
try { $flag = (Get-Content "$env:LOCALAPPDATA\Microsoft\PowerToys\settings.json" -Raw | ConvertFrom-Json).enabled.$EnabledKey } catch {}
}
[pscustomobject]@{ SettingsHwnd = $h; Selector = $sel; State = $state; EnabledFlag = $flag; Matched = ($state -eq $want) }
}

View File

@@ -1,100 +0,0 @@
# 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."
}
}
}

View File

@@ -1,76 +0,0 @@
# 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
}
}

View File

@@ -1,94 +0,0 @@
# 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
}

View File

@@ -1,51 +0,0 @@
# 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.
# Guard Add-Type so this script can be dot-sourced / re-run in the same session without
# throwing "type already exists" on the second invocation.
if (-not ('WTS' -as [type])) {
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
}

View File

@@ -1,133 +0,0 @@
# 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 } }
}

View File

@@ -1,66 +0,0 @@
# 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()
}

View File

@@ -1,111 +0,0 @@
# 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
}

View File

@@ -1,12 +1,12 @@
---
name: release-note-generation
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, generate PR review summaries locally for release notes, update README for a new release, manage PR milestones, collect PRs between commits/tags, or prepare release assets (download installers and compute installer hashes).
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, request Copilot reviews for PRs, update README for a new release, manage PR milestones, or collect PRs between commits/tags. Supports PR collection by milestone or commit range, milestone assignment, grouping by label, summarization with external contributor attribution, and README version bumping.
license: Complete terms in LICENSE.txt
---
# Release Note Generation Skill
Generate professional release notes for PowerToys milestones by collecting merged PRs, summarizing each PR with the local CLI agent, grouping by label, and producing user-facing summaries.
Generate professional release notes for PowerToys milestones by collecting merged PRs, requesting Copilot code reviews, grouping by label, and producing user-facing summaries.
## Output Directory
@@ -26,17 +26,16 @@ Generated Files/ReleaseNotes/
- Generate release notes for a milestone
- Summarize PRs merged in a release
- Generate per-PR review summaries locally for release-notes copy
- Request Copilot reviews for milestone PRs
- Assign milestones to PRs missing them
- Collect PRs between two commits/tags
- Update README.md for a new version
- Prepare GitHub release assets (download installers/symbols + compute hashes)
## Prerequisites
- **GitHub CLI (`gh`) installed and authenticated** — The collection script uses `gh pr view` and `gh api graphql` to fetch PR metadata and co-author information. Run `gh auth status` to verify; if not logged in, run `gh auth login` first. See [Step 1.0.0](./references/step1-collection.md) for details.
- MCP Server: github-mcp-server installed (used to fetch PR diffs/files for the local-agent review step)
- For [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) only: **Azure CLI** authenticated against the Microsoft tenant (`az login`) with the `azure-devops` extension; access to the `microsoft/Dart` ADO project
- GitHub CLI (`gh`) installed and authenticated
- MCP Server: github-mcp-server installed
- GitHub Copilot code review enabled for the org/repo
## Required Variables
@@ -50,10 +49,6 @@ Generated Files/ReleaseNotes/
```
┌────────────────────────────────┐
│ 1.0 Verify gh auth + MemberList │
└────────────────────────────────┘
┌────────────────────────────────┐
│ 1.1 Collect PRs (stable range) │
└────────────────────────────────┘
@@ -66,12 +61,12 @@ Generated Files/ReleaseNotes/
└────────────────────────────────┘
┌────────────────────────────────┐
│ 3.1 Local-agent PR summaries
│ (writes CopilotSummary) │
│ 3.1 Request Reviews (Copilot)
└────────────────────────────────┘
┌────────────────────────────────┐
│ 3.2 (Optional) Refresh PR data │
│ 3.2 Refresh PR data
│ (CopilotSummary) │
└────────────────────────────────┘
┌────────────────────────────────┐
@@ -90,11 +85,10 @@ Generated Files/ReleaseNotes/
| Step | Action | Details |
|------|--------|---------|
| 1.0 | Verify prerequisites | `gh auth status` must pass; generate MemberList.md |
| 1.1 | Collect PRs | From previous release tag on `stable` branch → `sorted_prs.csv` |
| 1.2 | Assign Milestones | Ensure all PRs have correct milestone |
| 2.12.4 | Label PRs | Auto-suggest + human label low-confidence |
| 3.13.3 | Reviews & Grouping | Local agent summarizes each PR diff into `CopilotSummary` → (optional refresh) → group by label |
| 3.13.3 | Reviews & Grouping | Request Copilot reviews → refresh → group by label |
| 4.14.2 | Summaries & Final | Generate grouped summaries, then consolidate |
## Detailed workflow docs
@@ -115,7 +109,6 @@ Do not read all steps at once—only read the step you are executing.
| [group-prs-by-label.ps1](./scripts/group-prs-by-label.ps1) | Group PRs into CSVs |
| [collect-or-apply-milestones.ps1](./scripts/collect-or-apply-milestones.ps1) | Assign milestones |
| [diff_prs.ps1](./scripts/diff_prs.ps1) | Incremental PR diff |
| [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) | Download installers + symbols from an ADO build, compute SHA256, emit the "Installer Hashes" markdown table for the GitHub release page |
## References
@@ -135,6 +128,5 @@ Do not read all steps at once—only read the step you are executing.
|-------|----------|
| `gh` command not found | Install GitHub CLI and add to PATH |
| No PRs returned | Verify milestone title matches exactly |
| Empty `CopilotSummary` for many PRs | Run Step 3.1 (local-agent summaries). Do **not** use `mcp_github_request_copilot_review` from a CLI/coding agent — the GitHub API rejects bot-initiated review requests, so the column will stay empty. |
| Empty CopilotSummary | Request Copilot reviews first, then re-run dump |
| Many unlabeled PRs | Return to labeling step before grouping |
| `prepare-release-assets.ps1` fails with "Failed to acquire ADO access token" | Run `az login` and ensure you have access to the `microsoft/Dart` ADO project |

View File

@@ -1,9 +1,9 @@
- Added mouse button actions so you can choose what left, right, or middle click does in [#1234](https://github.com/microsoft/PowerToys/pull/1234) by [@PesBandi](https://github.com/PesBandi)
- Added mouse button actions so you can choose what left, right, or middle click does. Thanks [@PesBandi](https://github.com/PesBandi)!
- Aligned window styling with current Windows theme for a cleaner look in [#1235](https://github.com/microsoft/PowerToys/pull/1235) by [@sadirano](https://github.com/sadirano)
- Aligned window styling with current Windows theme for a cleaner look. Thanks [@sadirano](https://github.com/sadirano)!
- Ensured screen readers are notified when the selected item in the list changes for better accessibility in [#1236](https://github.com/microsoft/PowerToys/pull/1236)
- Ensured screen readers are notified when the selected item in the list changes for better accessibility.
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours in [#1237](https://github.com/microsoft/PowerToys/pull/1237)
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours.
- Fixed Alt+Left Arrow navigation not working when search box contains text in [#1238](https://github.com/microsoft/PowerToys/pull/1238) by [@jiripolasek](https://github.com/jiripolasek)
- Fixed Alt+Left Arrow navigation not working when search box contains text. Thanks [@jiripolasek](https://github.com/jiripolasek)!

View File

@@ -1,7 +1,6 @@
# Step 1: Collection and Milestones
## 1.0 To-do
- 1.0.0 Verify GitHub CLI authentication (REQUIRED)
- 1.0.1 Generate MemberList.md (REQUIRED)
- 1.1 Collect PRs
- 1.2 Assign Milestones (REQUIRED)
@@ -21,34 +20,6 @@
---
## 1.0.0 Verify GitHub CLI Authentication (REQUIRED)
⚠️ **BLOCKING:** The collection script requires an authenticated `gh` CLI to fetch PR metadata and co-author information via GitHub's GraphQL API. Without authentication, PR data and `NeedThanks` attribution will be incomplete.
### Check authentication status
```powershell
gh auth status
```
**If authenticated:** You'll see `Logged in to github.com account <username>`. Proceed to 1.0.1.
**If NOT authenticated:** Run the login flow before continuing:
```powershell
# Interactive login (opens browser for OAuth)
gh auth login --hostname github.com --web
# Or use a personal access token
gh auth login --with-token <<< "YOUR_GITHUB_TOKEN"
```
**Required scopes:** `repo` (for reading PR data and assigning milestones)
After login, verify again with `gh auth status` and confirm exit code 0.
---
## 1.0.1 Generate MemberList.md (REQUIRED)
Create `Generated Files/ReleaseNotes/MemberList.md` from the **PowerToys core team** section in [COMMUNITY.md](../../../COMMUNITY.md).
@@ -109,8 +80,6 @@ The script detects both merge commits (`Merge pull request #12345`) and squash c
**Output** (in `Generated Files/ReleaseNotes/`):
- `milestone_prs.json` - raw PR data
- `sorted_prs.csv` - sorted PR list with columns: Id, Title, Labels, Author, Url, Body, CopilotSummary, NeedThanks
- `Author`: Comma-separated list of all contributors (PR opener + co-authors from commit trailers)
- `NeedThanks`: Comma-separated list of external contributors to thank (excludes core team members from MemberList.md). Empty string means no thanks needed.
---

View File

@@ -1,40 +1,22 @@
# Step 3: Local Agent Reviews and Grouping
# Step 3: Copilot Reviews and Grouping
## 3.0 To-do
- 3.1 Generate PR Summaries with the Local Agent
- 3.2 (Optional) Refresh PR Data
- 3.1 Request Copilot Reviews (Agent Mode)
- 3.2 Refresh PR Data
- 3.3 Group PRs by Label
## 3.1 Generate PR Summaries with the Local Agent
## 3.1 Request Copilot Reviews (Agent Mode)
> ⚠️ **Do not use `mcp_github_request_copilot_review` (or any "request Copilot review" tool that calls the GitHub API).**
> When this skill is driven from a CLI / coding agent, the request is made from a bot identity and the GitHub API rejects it ("Bot reviewers cannot be requested"). The PR ends up with no Copilot review and `CopilotSummary` stays empty.
>
> Instead, **the local agent that is running this skill performs the review itself** and writes the summary directly into `sorted_prs.csv`.
Use MCP tools to request Copilot reviews for all PRs in `Generated Files/ReleaseNotes/sorted_prs.csv`:
For every PR listed in `Generated Files/ReleaseNotes/sorted_prs.csv` whose `CopilotSummary` is empty:
1. Fetch the PR diff using a tool that does **not** post anything back to GitHub. Any of these works:
- `mcp_github_pull_request_read` with `method: get_diff`
- `mcp_github_pull_request_read` with `method: get_files` (when the diff is large)
- `gh pr diff <PR_NUMBER> --repo microsoft/PowerToys`
2. Read the PR title, body, and diff. Produce a 13 sentence, user-facing summary in the same style as a Copilot PR review (focus on observable behavior change, not implementation details).
3. Write the summary into the `CopilotSummary` column for that PR row in `Generated Files/ReleaseNotes/sorted_prs.csv`. Preserve all other columns and the existing row order.
**Batching guidance**
- Process PRs in the order they appear in `sorted_prs.csv`.
- Generate summaries for **all** PRs in one pass before continuing to Step 3.3, so the human reviewer can validate them together.
- For very large diffs, summarize from `get_files` (filenames + per-file patches) rather than the full diff.
- Skip PRs that already have a non-empty `CopilotSummary` (e.g. PRs where a human reviewer already pasted one). Do not overwrite existing summaries.
**Why not post the summary back to the PR?** Posting a comment from the agent's identity would not be picked up by `dump-prs-since-commit.ps1` (which only matches Copilot bot authors), and it adds noise to the PR. Writing straight into the CSV keeps the artifact self-contained.
- Use `mcp_github_request_copilot_review` for each PR ID
- Do NOT generate or run scripts for this step
---
## 3.2 (Optional) Refresh PR Data
## 3.2 Refresh PR Data
Only re-run the collection script if PR metadata on GitHub has changed (new labels, retitled PRs, etc.) since Step 1.1. **Skip this step if you only want to preserve the locally generated `CopilotSummary` values from Step 3.1**, because re-running the dump will overwrite the CSV.
Re-run the collection script to capture Copilot review summaries into the `CopilotSummary` column:
```powershell
pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 `
@@ -42,8 +24,6 @@ pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1
-OutputDir 'Generated Files/ReleaseNotes'
```
If you do refresh, redo Step 3.1 afterwards to repopulate `CopilotSummary`.
---
## 3.3 Group PRs by Label
@@ -55,4 +35,3 @@ pwsh ./.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 -Cs
Creates `Generated Files/ReleaseNotes/grouped_csv/` with one CSV per label combination.
**Validation:** The `Unlabeled.csv` file should be minimal (ideally empty). If many PRs remain unlabeled, return to Step 2 (see [step2-labeling.md](./step2-labeling.md)).

View File

@@ -16,7 +16,7 @@ For each CSV in `Generated Files/ReleaseNotes/grouped_csv/`, create a markdown f
- Use the “Verb-ed + Scenario + Impact” sentence structure—make readers think, “Thats exactly what I need” or “Yes, thats an awesome fix.”; The "impact" can be end-user focused (written to convey user excitement) or technical (performance/stability) when user-facing impact is minimal.
- If nothing special on impact or unclear impact, mark as needing human summary
- Source from Title, Body, and CopilotSummary (prefer CopilotSummary when available)
- The `NeedThanks` column contains a comma-separated list of external contributor usernames who should be credited (empty = no attribution needed, all authors are core team). For each non-empty `NeedThanks` value, append a `by` attribution that lists **every** contributor, matching GitHub's standard contributor-attribution style: `by [@user1](https://github.com/user1)` for a single contributor, `by [@user1](https://github.com/user1) and [@user2](https://github.com/user2)` for two, or `by [@user1](https://github.com/user1), [@user2](https://github.com/user2), and [@user3](https://github.com/user3)` for three or more. In the final consolidated release notes (Step 4.2), the attribution follows the PR link, e.g. `…in [#1234](url) by [@user](url)`. Do not use "Thanks @user!" phrasing.
- If the column `NeedThanks` in CSV is `True`, append: `Thanks [@Author](https://github.com/Author)!`
- Do NOT include PR numbers in bullet lines
- Do NOT mention “security” or “privacy” issues, since these are not known and could be leveraged by attackers in earlier versions. Instead, describe the user-facing scenario, usage, or impact.
- If confidence < 70%, write: `Human Summary Needed: <PR full link>`
@@ -72,13 +72,13 @@ Some items in the Development section may overlap and should be moved to the Mod
## Advanced Paste
- Wrapped paste option lists in a single ScrollViewer in [#5678](https://github.com/microsoft/PowerToys/pull/5678)
- Added image input handling for AI-powered transformations in [#5679](https://github.com/microsoft/PowerToys/pull/5679)
- Wrapped paste option lists in a single ScrollViewer
- Added image input handling for AI-powered transformations
...
## Awake
- Fixed timed mode expiration in [#5680](https://github.com/microsoft/PowerToys/pull/5680) by [@daverayment](https://github.com/daverayment)
- Fixed timed mode expiration. Thanks [@daverayment](https://github.com/daverayment)!
...
---

View File

@@ -42,7 +42,30 @@ param(
[string]$OutputJson = "milestone_prs.json"
)
# (See top-level synopsis above for full documentation)
<#
.SYNOPSIS
Dump merged PR information whose merge commits are reachable from EndCommit but not from StartCommit.
.DESCRIPTION
Uses git rev-list to compute commits in the (StartCommit, EndCommit] range, extracts PR numbers from merge commit messages,
queries GitHub (gh CLI) for details, then outputs a CSV.
PR merge commit messages in PowerToys generally contain patterns like:
Merge pull request #12345 from ...
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -Branch stable
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv changes.csv
.NOTES
Requires: gh CLI authenticated; git available in working directory (must be inside PowerToys repo clone).
CopilotSummary behavior:
- Attempts to locate the latest GitHub Copilot authored review (preferred).
- If no review is found, lazily fetches PR comments to look for a Copilot-authored comment.
- Normalizes whitespace and strips newlines. Empty when no Copilot activity detected.
- Run with -Verbose to see whether the summary came from a 'review' or 'comment' source.
#>
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
@@ -128,11 +151,11 @@ catch {
}
Write-Info "Collecting commits between $startSha..$endSha (excluding start, including end)."
# Get list of commits reachable from end but not from start.
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
# `$startSha..$endSha` must be passed as a single string argument.
$rangeArg = "$startSha..$endSha"
$commitList = git rev-list $rangeArg
# Get list of commits reachable from end but not from start.
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
# `$startSha..$endSha` must be passed as a single string argument.
$rangeArg = "$startSha..$endSha"
$commitList = git rev-list $rangeArg
# Normalize list (filter out empty strings)
$normalizedCommits = $commitList | Where-Object { $_ -and $_.Trim() -ne '' }
@@ -187,63 +210,6 @@ $prNumbers = $mergeCommits | Select-Object -ExpandProperty Pr -Unique | Sort-Obj
Write-Info ("Found {0} unique PRs: {1}" -f $prNumbers.Count, ($prNumbers -join ', '))
Write-DebugMsg ("Total merge commits examined: {0}" -f $mergeCommits.Count)
# Build a map of PR number → list of commit SHAs (for co-author extraction)
$prToCommits = @{}
foreach ($mc in $mergeCommits) {
if (-not $prToCommits.ContainsKey($mc.Pr)) {
$prToCommits[$mc.Pr] = @()
}
$prToCommits[$mc.Pr] += $mc.Sha
}
<#
.SYNOPSIS
Get all authors (including co-authors) for a set of commits via GitHub GraphQL API.
.DESCRIPTION
Uses the Commit.authors field in GitHub's GraphQL API which natively includes
co-authors (from Co-authored-by trailers). Returns GitHub usernames (login)
without any email parsing — GitHub resolves the association for us.
NOTE: For squash merges this captures all co-authors correctly because GitHub
preserves Co-authored-by trailers in the squash commit. For traditional merge
commits, only the merger's author is returned — co-authors on individual PR
commits are not traversed. This is acceptable because PowerToys primarily uses
squash merging.
#>
function Get-CommitAuthors {
param(
[string[]]$CommitShas,
[string]$RepoFullName = "microsoft/PowerToys"
)
$parts = $RepoFullName -split '/'
$owner = $parts[0]
$repoName = $parts[1]
$allAuthors = @()
foreach ($sha in $CommitShas) {
try {
$query = "{ repository(owner: `"$owner`", name: `"$repoName`") { object(expression: `"$sha`") { ... on Commit { authors(first: 20) { nodes { user { login } name } } } } } }"
$result = gh api graphql -f query="$query" 2>$null | ConvertFrom-Json
$nodes = $result.data.repository.object.authors.nodes
if ($nodes) {
foreach ($node in $nodes) {
if ($node.user -and $node.user.login) {
$allAuthors += $node.user.login
} else {
# User without a GitHub account (rare) — use display name as fallback
Write-DebugMsg "Commit $sha has an author without GitHub account: $($node.name)"
}
}
}
}
catch {
Write-DebugMsg "GraphQL authors query failed for commit ${sha}: $_"
}
}
return $allAuthors | Select-Object -Unique
}
# Query GitHub for each PR
$prDetails = @()
function Get-CopilotSummaryFromPrJson {
@@ -341,45 +307,22 @@ foreach ($pr in $prNumbers) {
$bodyValue = if ($json.body) { ($json.body -replace "`r", '') -replace "`n", ' ' } else { '' }
$bodyValue = $bodyValue -replace '\s+', ' '
# Collect all contributors: PR author + co-authors from commit messages
# Determine if author needs thanks (not in member list)
$authorLogin = $json.author.login
$allContributors = @($authorLogin)
# Extract all authors (including co-authors) from associated commits via GitHub GraphQL API
if ($prToCommits.ContainsKey([int]$pr)) {
$commitAuthors = Get-CommitAuthors -CommitShas $prToCommits[[int]$pr] -RepoFullName $Repo
if ($commitAuthors) {
$allContributors += $commitAuthors
}
$needThanks = $true
if ($memberList.Count -gt 0 -and $authorLogin) {
$needThanks = -not ($memberList -contains $authorLogin)
}
# Deduplicate contributors (case-insensitive)
$allContributors = $allContributors | Where-Object { $_ } | Sort-Object -Unique
# Filter to only external contributors (not in member list) for thanks
$externalContributors = @()
if ($memberList.Count -gt 0) {
$externalContributors = $allContributors | Where-Object { -not ($memberList -contains $_) }
} else {
$externalContributors = $allContributors
}
# Author column: all contributors (comma-separated)
$authorField = ($allContributors -join ', ')
# NeedThanks column: comma-separated list of external contributors who
# deserve thanks attribution. Empty string means no thanks needed.
$needThanksField = ($externalContributors -join ', ')
$prDetails += [PSCustomObject]@{
Id = $json.number
Title = $json.title
Labels = $labelNames
Author = $authorField
Author = $authorLogin
Url = $json.url
Body = $bodyValue
CopilotSummary = $copilot.Summary
NeedThanks = $needThanksField
NeedThanks = $needThanks
}
}
catch {

View File

@@ -1,334 +0,0 @@
<#
.SYNOPSIS
Prepares the binary assets for a PowerToys GitHub release: downloads the
four installers (per-user/per-machine x x64/arm64) and the symbol archives
from an ADO pipeline build, computes SHA256 hashes, and emits the
"Installer Hashes" markdown table.
.DESCRIPTION
Given an ADO Dart pipeline build id (e.g. from
https://microsoft.visualstudio.com/Dart/_build/results?buildId=NNN),
downloads the four installer EXEs and the per-arch symbol zips into a
single per-version folder, then writes a hashes.md alongside them with a
markdown table ready to paste into the GitHub release notes.
Requires: az login (Azure CLI authenticated), az devops extension.
.EXAMPLE
.\prepare-release-assets.ps1 -BuildId 145505247
.\prepare-release-assets.ps1 -BuildId 145505247 -OutputFolder D:\Releases
#>
param(
[Parameter(Mandatory = $true)]
[int]$BuildId,
[string]$OutputFolder = "$env:USERPROFILE\Downloads",
[string]$Organization = "https://dev.azure.com/microsoft",
[string]$Project = "Dart",
[string]$GitHubRepo = "microsoft/PowerToys"
)
$ErrorActionPreference = "Stop"
$env:AZURE_CORE_NO_PROMPT = "true"
# --- Helpers -----------------------------------------------------------------
# Invoke an `az` CLI command and capture stderr in $script:LastAzError so
# callers can surface the underlying message (expired login, blocked extension,
# tenant policy, ...) instead of swallowing it with `2>$null`.
function Invoke-Az {
$tmpErr = [System.IO.Path]::GetTempFileName()
try {
$output = & az @args 2>$tmpErr
# Get-Content -Raw returns $null for an empty file, and calling .Trim()
# on $null throws under $ErrorActionPreference = 'Stop' -- which would
# turn every successful (no-stderr) az call into a fatal error. Guard
# explicitly so $script:LastAzError is always a (possibly empty) string.
$rawErr = Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue
$script:LastAzError = if ($null -eq $rawErr) { '' } else { $rawErr.Trim() }
return $output
}
finally {
Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue
}
}
# Build an ADO artifact download URL from scratch instead of regex-replacing
# the URL returned by `az pipelines runs artifact list`. Preserves any other
# query parameters and only swaps `format` and `subPath`, so we don't break if
# the upstream URL shape ever changes.
function Get-ArtifactDownloadUrl {
param(
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][string]$SubPath,
[Parameter(Mandatory)][ValidateSet('file', 'zip')][string]$Format
)
$encodedSubPath = [Uri]::EscapeDataString($SubPath)
$idx = $BaseUrl.IndexOf('?')
if ($idx -lt 0) {
return "${BaseUrl}?format=${Format}&subPath=${encodedSubPath}"
}
$base = $BaseUrl.Substring(0, $idx)
$kept = $BaseUrl.Substring($idx + 1) -split '&' | Where-Object {
$_ -and -not ($_ -match '^(format|subPath)=')
}
$kept = @($kept) + @("format=$Format", "subPath=$encodedSubPath")
return "${base}?$($kept -join '&')"
}
# Download a single ADO artifact file with bearer auth and a small retry/backoff
# loop. A transient network blip on a ~200 MB installer or symbol zip otherwise
# aborts the entire release-prep run.
function Invoke-AdoDownload {
param(
[Parameter(Mandatory)][string]$Url,
[Parameter(Mandatory)][string]$DestPath,
[Parameter(Mandatory)][string]$Token,
[int]$MaxAttempts = 3
)
$lastError = $null
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("Authorization", "Bearer $Token")
try {
$webClient.DownloadFile($Url, $DestPath)
return
}
catch {
$lastError = $_
if (Test-Path $DestPath) {
Remove-Item $DestPath -Force -ErrorAction SilentlyContinue
}
if ($attempt -lt $MaxAttempts) {
$backoffSec = [int][Math]::Pow(2, $attempt) # 2, 4, 8 ...
Write-Host " Attempt $attempt failed: $($_.Exception.Message). Retrying in ${backoffSec}s..." -ForegroundColor Yellow
Start-Sleep -Seconds $backoffSec
}
}
finally {
$webClient.Dispose()
}
}
throw "Download failed after $MaxAttempts attempts. Last error: $($lastError.Exception.Message)`nURL: $Url"
}
# -----------------------------------------------------------------------------
# Work around broken az extensions: if the default extension dir has
# inaccessible files, redirect to a clean directory.
$defaultExtDir = "$env:USERPROFILE\.azure\cliextensions"
if (-not $env:AZURE_EXTENSION_DIR -and (Test-Path $defaultExtDir)) {
$broken = Get-ChildItem "$defaultExtDir\*\*.dist-info" -Directory -ErrorAction SilentlyContinue | Where-Object {
try { [System.IO.Directory]::GetFiles($_.FullName) | Out-Null; $false } catch { $true }
}
if ($broken) {
$cleanDir = "$env:USERPROFILE\.azure\cliextensions_clean"
Write-Host " Detected broken az extension, redirecting to $cleanDir" -ForegroundColor Yellow
$env:AZURE_EXTENSION_DIR = $cleanDir
if (-not (Test-Path $cleanDir)) { New-Item -ItemType Directory -Path $cleanDir -Force | Out-Null }
}
}
# Ensure azure-devops extension is installed
$ext = Invoke-Az extension list --query "[?name=='azure-devops']" -o tsv
if (-not $ext) {
Write-Host "Installing azure-devops extension..." -ForegroundColor Yellow
Invoke-Az extension add --name azure-devops --yes | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install azure-devops extension. (az: $script:LastAzError)"
exit 1
}
}
# Configure az devops defaults
Invoke-Az devops configure --defaults organization=$Organization project=$Project | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to configure az devops defaults. (az: $script:LastAzError)"
exit 1
}
# --- Step 1: Get build info to determine version ---
Write-Host "Fetching build $BuildId info..." -ForegroundColor Cyan
$buildJson = Invoke-Az pipelines build show --id $BuildId --output json
if (-not $buildJson) {
Write-Error "Could not fetch build $BuildId. Are you logged in (az login)? (az: $script:LastAzError)"
exit 1
}
$build = $buildJson | ConvertFrom-Json
$versionParam = $build.templateParameters.VersionNumber
if (-not $versionParam) {
Write-Error "Could not determine version from build $BuildId"
exit 1
}
Write-Host " Version: $versionParam" -ForegroundColor DarkGray
# --- Step 2: Get artifact metadata once ---
Write-Host "Fetching artifact metadata..." -ForegroundColor Cyan
$artifactsJson = Invoke-Az pipelines runs artifact list --run-id $BuildId --output json
if (-not $artifactsJson) {
Write-Error "Could not list artifacts for build $BuildId. (az: $script:LastAzError)"
exit 1
}
$artifacts = $artifactsJson | ConvertFrom-Json
# --- Step 3: Prepare destination folder ---
$destFolder = Join-Path $OutputFolder "PowerToys-v$versionParam"
if (-not (Test-Path $destFolder)) {
New-Item -ItemType Directory -Path $destFolder -Force | Out-Null
}
Write-Host " Destination: $destFolder" -ForegroundColor DarkGray
# --- Step 4: Get an ADO access token once ---
$token = Invoke-Az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query accessToken -o tsv
if (-not $token) {
Write-Error "Failed to acquire ADO access token. Run 'az login' first. (az: $script:LastAzError)"
exit 1
}
# --- Step 5: Define the four installers to download ---
$targets = @(
[pscustomobject]@{ Description = "Per user - x64"; Scope = "perUser"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysUserSetup-$versionParam-x64.exe" }
[pscustomobject]@{ Description = "Per user - ARM64"; Scope = "perUser"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysUserSetup-$versionParam-arm64.exe" }
[pscustomobject]@{ Description = "Machine wide - x64"; Scope = "perMachine"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysSetup-$versionParam-x64.exe" }
[pscustomobject]@{ Description = "Machine wide - ARM64"; Scope = "perMachine"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysSetup-$versionParam-arm64.exe" }
)
# --- Step 6: Download each installer (skip if already present) ---
foreach ($t in $targets) {
$destPath = Join-Path $destFolder $t.FileName
if (Test-Path $destPath) {
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
Write-Host "[skip] $($t.FileName) already exists ($sizeMB MB)" -ForegroundColor DarkGray
continue
}
$artifact = $artifacts | Where-Object { $_.name -eq $t.Artifact }
if (-not $artifact) {
Write-Error "Artifact '$($t.Artifact)' not found in build $BuildId. Available: $(($artifacts | ForEach-Object name) -join ', ')"
exit 1
}
$fileUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath "/$($t.FileName)" -Format file
Write-Host "Downloading $($t.FileName) ..." -ForegroundColor Cyan
try {
Invoke-AdoDownload -Url $fileUrl -DestPath $destPath -Token $token
}
catch {
Write-Error "Download failed for $($t.FileName): $_"
exit 1
}
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
Write-Host " Saved ($sizeMB MB)" -ForegroundColor Green
}
# --- Step 6b: Download symbols (one zip per arch) ---
$symbolTargets = @(
[pscustomobject]@{ Arch = "x64"; Artifact = "build-x64-Release"; SubPath = "/symbols-x64" }
[pscustomobject]@{ Arch = "arm64"; Artifact = "build-arm64-Release"; SubPath = "/symbols-arm64" }
)
foreach ($s in $symbolTargets) {
$finalZip = Join-Path $destFolder "symbols-$($s.Arch).zip"
if (Test-Path $finalZip) {
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
Write-Host "[skip] symbols-$($s.Arch).zip already exists ($sizeMB MB)" -ForegroundColor DarkGray
continue
}
$artifact = $artifacts | Where-Object { $_.name -eq $s.Artifact }
if (-not $artifact) {
Write-Error "Artifact '$($s.Artifact)' not found in build $BuildId."
exit 1
}
# Symbols are downloaded as a folder => keep format=zip and append subPath
$symbolsUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath $s.SubPath -Format zip
$tmpZip = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N')).zip")
$tmpExtract = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N'))")
$stageRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-stage-$([Guid]::NewGuid().ToString('N'))")
try {
Write-Host "Downloading symbols-$($s.Arch).zip ..." -ForegroundColor Cyan
try {
Invoke-AdoDownload -Url $symbolsUrl -DestPath $tmpZip -Token $token
}
catch {
Write-Error "Symbols download failed for $($s.Arch): $_"
exit 1
}
Write-Host " Extracting..." -ForegroundColor DarkGray
Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force
# Walk down while the current dir holds exactly one subfolder and no files.
$current = Get-Item $tmpExtract
while ($true) {
$children = Get-ChildItem -LiteralPath $current.FullName -Force
$subDirs = @($children | Where-Object { $_.PSIsContainer })
$files = @($children | Where-Object { -not $_.PSIsContainer })
if ($subDirs.Count -eq 1 -and $files.Count -eq 0) {
$current = $subDirs[0]
}
else {
break
}
}
# Stage to a folder named symbols-<arch> so the zip extracts to that name.
$stageInner = Join-Path $stageRoot "symbols-$($s.Arch)"
New-Item -ItemType Directory -Path $stageInner -Force | Out-Null
Get-ChildItem -LiteralPath $current.FullName -Force | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $stageInner -Recurse -Force
}
Write-Host " Repacking to $finalZip ..." -ForegroundColor DarkGray
if (Test-Path $finalZip) { Remove-Item $finalZip -Force }
Compress-Archive -Path "$stageInner\*" -DestinationPath $finalZip -CompressionLevel Optimal
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
Write-Host " Saved symbols-$($s.Arch).zip ($sizeMB MB)" -ForegroundColor Green
}
catch {
# Don't leave a half-built zip behind if anything in the pipeline blew up.
if (Test-Path $finalZip) { Remove-Item $finalZip -Force -ErrorAction SilentlyContinue }
throw
}
finally {
Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue
Remove-Item $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
# --- Step 7: Compute SHA256 and build markdown ---
Write-Host "`nComputing SHA256 hashes..." -ForegroundColor Cyan
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("## Installer Hashes")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("| Description | Filename | sha256 hash |")
[void]$sb.AppendLine("| --- | --- | --- |")
foreach ($t in $targets) {
$destPath = Join-Path $destFolder $t.FileName
$hash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpper()
[void]$sb.AppendLine("| $($t.Description) | $($t.FileName) | $hash |")
Write-Host " $($t.FileName) $hash" -ForegroundColor DarkGray
}
$markdown = $sb.ToString()
$mdPath = Join-Path $destFolder "hashes.md"
Set-Content -Path $mdPath -Value $markdown -Encoding UTF8
Write-Host "`nMarkdown written to: $mdPath" -ForegroundColor Green
Write-Host "`n----- Installer Hashes -----`n" -ForegroundColor Yellow
Write-Host $markdown
Write-Host "Draft a new GitHub release at: https://github.com/$GitHubRepo/releases/new?tag=v$versionParam" -ForegroundColor Green

View File

@@ -1,21 +0,0 @@
The MIT License
Copyright (c) Microsoft Corporation. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -1,192 +0,0 @@
---
name: winmd-api-search
description: 'Find and explore Windows desktop APIs. Use when building features that need platform capabilities — camera, file access, notifications, UI controls, AI/ML, sensors, networking, etc. Discovers the right API for a task and retrieves full type details (methods, properties, events, enumeration values).'
license: Complete terms in LICENSE.txt
---
# WinMD API Search
This skill helps you find the right Windows API for any capability and get its full details. It searches a local cache of all WinMD metadata from:
- **Windows Platform SDK** — all `Windows.*` WinRT APIs (always available, no restore needed)
- **WinAppSDK / WinUI** — bundled as a baseline in the cache generator (always available, no restore needed)
- **NuGet packages** — any additional packages in restored projects that contain `.winmd` files
- **Project-output WinMD** — class libraries (C++/WinRT, C#) that produce `.winmd` as build output
Even on a fresh clone with no restore or build, you still get full Platform SDK + WinAppSDK coverage.
## When to Use This Skill
- User wants to build a feature and you need to find which API provides that capability
- User asks "how do I do X?" where X involves a platform feature (camera, files, notifications, sensors, AI, etc.)
- You need the exact methods, properties, events, or enumeration values of a type before writing code
- You're unsure which control, class, or interface to use for a UI or system task
## Prerequisites
- **.NET SDK 8.0 or later** — required to build the cache generator. Install from [dotnet.microsoft.com](https://dotnet.microsoft.com/download) if not available.
## Cache Setup (Required Before First Use)
All query and search commands read from a local JSON cache. **You must generate the cache before running any queries.**
```powershell
# All projects in the repo (recommended for first run)
.\.github\skills\winmd-api-search\scripts\Update-WinMdCache.ps1
# Single project
.\.github\skills\winmd-api-search\scripts\Update-WinMdCache.ps1 -ProjectDir <project-folder>
```
No project restore or build is needed for baseline coverage (Platform SDK + WinAppSDK). For additional NuGet packages, the project needs `dotnet restore` (which generates `project.assets.json`) or a `packages.config` file.
Cache is stored at `Generated Files\winmd-cache\`, deduplicated per-package+version.
### What gets indexed
| Source | When available |
|--------|----------------|
| Windows Platform SDK | Always (reads from local SDK install) |
| WinAppSDK (latest) | Always (bundled as baseline in cache generator) |
| WinAppSDK Runtime | When installed on the system (detected via `Get-AppxPackage`) |
| Project NuGet packages | After `dotnet restore` or with `packages.config` |
| Project-output `.winmd` | After project build (class libraries that produce WinMD) |
> **Note:** This cache directory should be in `.gitignore` — it's generated, not source.
## How to Use
Pick the path that matches the situation:
---
### Discover — "I don't know which API to use"
The user describes a capability in their own words. You need to find the right API.
**0. Ensure the cache exists**
If the cache hasn't been generated yet, run `Update-WinMdCache.ps1` first — see [Cache Setup](#cache-setup-required-before-first-use) above.
**1. Translate user language → search keywords**
Map the user's daily language to programming terms. Try multiple variations:
| User says | Search keywords to try (in order) |
|-----------|-----------------------------------|
| "take a picture" | `camera`, `capture`, `photo`, `MediaCapture` |
| "load from disk" | `file open`, `picker`, `FileOpen`, `StorageFile` |
| "describe what's in it" | `image description`, `Vision`, `Recognition` |
| "show a popup" | `dialog`, `flyout`, `popup`, `ContentDialog` |
| "drag and drop" | `drag`, `drop`, `DragDrop` |
| "save settings" | `settings`, `ApplicationData`, `LocalSettings` |
Start with simple everyday words. If results are weak or irrelevant, try the more technical variation.
**2. Run searches**
```powershell
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action search -Query "<keyword>"
```
This returns ranked namespaces with top matching types and the **JSON file path**.
If results have **low scores (below 60) or are irrelevant**, fall back to searching online documentation:
1. Use web search to find the right API on Microsoft Learn, for example:
- `site:learn.microsoft.com/uwp/api <capability keywords>` for `Windows.*` APIs
- `site:learn.microsoft.com/windows/windows-app-sdk/api/winrt <capability keywords>` for `Microsoft.*` WinAppSDK APIs
2. Read the documentation pages to identify which type matches the user's requirement.
3. Once you know the type name, come back and use `-Action members` or `-Action enums` to get the exact local signatures.
**3. Read the JSON to choose the right API**
Read the file at the path(s) from the top results. The JSON has all types in that namespace — full members, signatures, parameters, return types, enumeration values.
Read and decide which types and members fit the user's requirement.
**4. Look up official documentation for context**
The cache contains only signatures — no descriptions or usage guidance. For explanations, examples, and remarks, look up the type on Microsoft Learn:
| Namespace prefix | Documentation base URL |
|-----------------|----------------------|
| `Windows.*` | `https://learn.microsoft.com/uwp/api/{fully.qualified.typename}` |
| `Microsoft.*` (WinAppSDK) | `https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/{fully.qualified.typename}` |
For example, `Microsoft.UI.Xaml.Controls.NavigationView` maps to:
`https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.controls.navigationview`
**5. Use the API knowledge to answer or write code**
---
### Lookup — "I know the API, show me the details"
You already know (or suspect) the type or namespace name. Go direct:
```powershell
# Get all members of a known type
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action members -TypeName "Microsoft.UI.Xaml.Controls.NavigationView"
# Get enum values
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action enums -TypeName "Microsoft.UI.Xaml.Visibility"
# List all types in a namespace
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action types -Namespace "Microsoft.UI.Xaml.Controls"
# Browse namespaces
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action namespaces -Filter "Microsoft.UI"
```
If you need full detail beyond what `-Action members` shows, use `-Action search` to get the JSON file path, then read the JSON file directly.
---
### Other Commands
```powershell
# List cached projects
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action projects
# List packages for a project
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action packages
# Show stats
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action stats
```
> If only one project is cached, `-Project` is auto-selected.
> If multiple projects exist, add `-Project <name>` (use `-Action projects` to see available names).
> In scan mode, manifest names include a short hash suffix to avoid collisions; you can pass the base project name without the suffix if it's unambiguous.
## Search Scoring
The search ranks type names and member names against your query:
| Score | Match type | Example |
|-------|-----------|---------|
| 100 | Exact name | `Button``Button` |
| 80 | Starts with | `Navigation``NavigationView` |
| 60 | Contains | `Dialog``ContentDialog` |
| 50 | PascalCase initials | `ASB``AutoSuggestBox` |
| 40 | Multi-keyword AND | `navigation item``NavigationViewItem` |
| 20 | Fuzzy character match | `NavVw``NavigationView` |
Results are grouped by namespace. Higher-scored namespaces appear first.
## Troubleshooting
| Issue | Fix |
|-------|-----|
| "Cache not found" | Run `Update-WinMdCache.ps1` |
| "Multiple projects cached" | Add `-Project <name>` |
| "Namespace not found" | Use `-Action namespaces` to list available ones |
| "Type not found" | Use fully qualified name (e.g., `Microsoft.UI.Xaml.Controls.Button`) |
| Stale after NuGet update | Re-run `Update-WinMdCache.ps1` |
| Cache in git history | Add `Generated Files/` to `.gitignore` |
## References
- [Windows Platform SDK API reference](https://learn.microsoft.com/uwp/api/) — documentation for `Windows.*` namespaces
- [Windows App SDK API reference](https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/) — documentation for `Microsoft.*` WinAppSDK namespaces

View File

@@ -1,505 +0,0 @@
<#
.SYNOPSIS
Query WinMD API metadata from cached JSON files.
.DESCRIPTION
Reads pre-built JSON cache of WinMD types, members, and namespaces.
The cache is organized per-package (deduplicated) with project manifests
that map each project to its referenced packages.
Supports listing namespaces, types, members, searching, enum value lookup,
and listing cached projects/packages.
.PARAMETER Action
The query action to perform:
- projects : List cached projects
- packages : List packages for a project
- stats : Show aggregate statistics for a project
- namespaces : List all namespaces (optional -Filter prefix)
- types : List types in a namespace (-Namespace required)
- members : List members of a type (-TypeName required)
- search : Search types and members by name (-Query required)
- enums : List enum values (-TypeName required)
.PARAMETER Project
Project name to query. Auto-selected if only one project is cached.
Use -Action projects to list available projects.
.PARAMETER Namespace
Namespace to query types from (used with -Action types).
.PARAMETER TypeName
Full type name e.g. "Microsoft.UI.Xaml.Controls.Button" (used with -Action members, enums).
.PARAMETER Query
Search query string (used with -Action search).
.PARAMETER Filter
Optional prefix filter for namespaces (used with -Action namespaces).
.PARAMETER CacheDir
Path to the winmd-cache directory. Defaults to "Generated Files\winmd-cache"
relative to the workspace root.
.PARAMETER MaxResults
Maximum number of results to return for search. Defaults to 30.
.EXAMPLE
.\Invoke-WinMdQuery.ps1 -Action projects
.\Invoke-WinMdQuery.ps1 -Action packages -Project BlankWinUI
.\Invoke-WinMdQuery.ps1 -Action stats -Project BlankWinUI
.\Invoke-WinMdQuery.ps1 -Action namespaces -Filter "Microsoft.UI"
.\Invoke-WinMdQuery.ps1 -Action types -Namespace "Microsoft.UI.Xaml.Controls"
.\Invoke-WinMdQuery.ps1 -Action members -TypeName "Microsoft.UI.Xaml.Controls.Button"
.\Invoke-WinMdQuery.ps1 -Action search -Query "NavigationView"
.\Invoke-WinMdQuery.ps1 -Action enums -TypeName "Microsoft.UI.Xaml.Visibility"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('projects', 'packages', 'stats', 'namespaces', 'types', 'members', 'search', 'enums')]
[string]$Action,
[string]$Project,
[string]$Namespace,
[string]$TypeName,
[string]$Query,
[string]$Filter,
[string]$CacheDir,
[int]$MaxResults = 30
)
# ─── Resolve cache directory ─────────────────────────────────────────────────
if (-not $CacheDir) {
# Convention: skill lives at .github/skills/winmd-api-search/scripts/
# so workspace root is 4 levels up from $PSScriptRoot.
$scriptDir = $PSScriptRoot
$root = (Resolve-Path (Join-Path $scriptDir '..\..\..\..')).Path
$CacheDir = Join-Path $root 'Generated Files\winmd-cache'
}
if (-not (Test-Path $CacheDir)) {
Write-Error "Cache not found at: $CacheDir`nRun: .\Update-WinMdCache.ps1 (from .github\skills\winmd-api-search\scripts\)"
exit 1
}
# ─── Project resolution helpers ──────────────────────────────────────────────
function Get-CachedProjects {
$projectsDir = Join-Path $CacheDir 'projects'
if (-not (Test-Path $projectsDir)) { return @() }
Get-ChildItem $projectsDir -Filter '*.json' | ForEach-Object { $_.BaseName }
}
function Resolve-ProjectManifest {
param([string]$Name)
$projectsDir = Join-Path $CacheDir 'projects'
if (-not (Test-Path $projectsDir)) {
Write-Error "No projects cached. Run Update-WinMdCache.ps1 first."
exit 1
}
if ($Name) {
$path = Join-Path $projectsDir "$Name.json"
if (-not (Test-Path $path)) {
# Scan mode appends a hash suffix -- try prefix match
$matching = @(Get-ChildItem $projectsDir -Filter "${Name}_*.json" -ErrorAction SilentlyContinue)
if ($matching.Count -eq 1) {
return Get-Content $matching[0].FullName -Raw | ConvertFrom-Json
}
if ($matching.Count -gt 1) {
$names = ($matching | ForEach-Object { $_.BaseName }) -join ', '
Write-Error "Multiple projects match '$Name'. Specify the full name: $names"
exit 1
}
$available = (Get-CachedProjects) -join ', '
Write-Error "Project '$Name' not found. Available: $available"
exit 1
}
return Get-Content $path -Raw | ConvertFrom-Json
}
# Auto-select if only one project
$manifests = Get-ChildItem $projectsDir -Filter '*.json' -ErrorAction SilentlyContinue
if ($manifests.Count -eq 0) {
Write-Error "No projects cached. Run Update-WinMdCache.ps1 first."
exit 1
}
if ($manifests.Count -eq 1) {
return Get-Content $manifests[0].FullName -Raw | ConvertFrom-Json
}
$available = ($manifests | ForEach-Object { $_.BaseName }) -join ', '
Write-Error "Multiple projects cached -- use -Project to specify. Available: $available"
exit 1
}
function Get-PackageCacheDirs {
param($Manifest)
$dirs = @()
foreach ($pkg in $Manifest.packages) {
$dir = Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version
if (Test-Path $dir) {
$dirs += $dir
}
}
return $dirs
}
# ─── Action: projects ────────────────────────────────────────────────────────
function Show-Projects {
$projects = Get-CachedProjects
if ($projects.Count -eq 0) {
Write-Output "No projects cached."
return
}
Write-Output "Cached projects ($($projects.Count)):"
foreach ($p in $projects) {
$manifest = Get-Content (Join-Path (Join-Path $CacheDir 'projects') "$p.json") -Raw | ConvertFrom-Json
$pkgCount = $manifest.packages.Count
Write-Output " $p ($pkgCount package(s))"
}
}
# ─── Action: packages ────────────────────────────────────────────────────────
function Show-Packages {
$manifest = Resolve-ProjectManifest -Name $Project
Write-Output "Packages for project '$($manifest.projectName)' ($($manifest.packages.Count)):"
foreach ($pkg in $manifest.packages) {
$metaPath = Join-Path (Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version) 'meta.json'
if (Test-Path $metaPath) {
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
Write-Output " $($pkg.id)@$($pkg.version) -- $($meta.totalTypes) types, $($meta.totalMembers) members"
} else {
Write-Output " $($pkg.id)@$($pkg.version) -- (cache missing)"
}
}
}
# ─── Action: stats ───────────────────────────────────────────────────────────
function Show-Stats {
$manifest = Resolve-ProjectManifest -Name $Project
$totalTypes = 0
$totalMembers = 0
$totalNamespaces = 0
$totalWinMd = 0
foreach ($pkg in $manifest.packages) {
$metaPath = Join-Path (Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version) 'meta.json'
if (Test-Path $metaPath) {
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
$totalTypes += $meta.totalTypes
$totalMembers += $meta.totalMembers
$totalNamespaces += $meta.totalNamespaces
$totalWinMd += $meta.winMdFiles.Count
}
}
Write-Output "WinMD Index Statistics -- $($manifest.projectName)"
Write-Output "======================================"
Write-Output " Packages: $($manifest.packages.Count)"
Write-Output " Namespaces: $totalNamespaces (may overlap across packages)"
Write-Output " Types: $totalTypes"
Write-Output " Members: $totalMembers"
Write-Output " WinMD files: $totalWinMd"
}
# ─── Action: namespaces ──────────────────────────────────────────────────────
function Get-Namespaces {
param([string]$Prefix)
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
$allNs = @()
foreach ($dir in $dirs) {
$nsFile = Join-Path $dir 'namespaces.json'
if (Test-Path $nsFile) {
$allNs += (Get-Content $nsFile -Raw | ConvertFrom-Json)
}
}
$allNs = $allNs | Sort-Object -Unique
if ($Prefix) {
$allNs = $allNs | Where-Object { $_ -like "$Prefix*" }
}
$allNs | ForEach-Object { Write-Output $_ }
}
# ─── Action: types ───────────────────────────────────────────────────────────
function Get-TypesInNamespace {
param([string]$Ns)
if (-not $Ns) {
Write-Error "-Namespace is required for 'types' action."
exit 1
}
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
$safeFile = $Ns.Replace('.', '_') + '.json'
$found = $false
$seen = @{}
foreach ($dir in $dirs) {
$filePath = Join-Path $dir "types\$safeFile"
if (-not (Test-Path $filePath)) { continue }
$found = $true
$types = Get-Content $filePath -Raw | ConvertFrom-Json
foreach ($t in $types) {
if ($seen.ContainsKey($t.fullName)) { continue }
$seen[$t.fullName] = $true
Write-Output "$($t.kind) $($t.fullName)$(if ($t.baseType) { " : $($t.baseType)" } else { '' })"
}
}
if (-not $found) {
Write-Error "Namespace not found: $Ns"
exit 1
}
}
# ─── Action: members ─────────────────────────────────────────────────────────
function Get-MembersOfType {
param([string]$FullName)
if (-not $FullName) {
Write-Error "-TypeName is required for 'members' action."
exit 1
}
$lastDot = $FullName.LastIndexOf('.')
if ($lastDot -lt 0) {
Write-Error "-TypeName must include a namespace (for example: 'MyNamespace.MyType'). Provided: $FullName"
exit 1
}
$ns = $FullName.Substring(0, $lastDot)
$safeFile = $ns.Replace('.', '_') + '.json'
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
foreach ($dir in $dirs) {
$filePath = Join-Path $dir "types\$safeFile"
if (-not (Test-Path $filePath)) { continue }
$types = Get-Content $filePath -Raw | ConvertFrom-Json
$type = $types | Where-Object { $_.fullName -eq $FullName }
if (-not $type) { continue }
Write-Output "$($type.kind) $($type.fullName)"
if ($type.baseType) { Write-Output " Extends: $($type.baseType)" }
Write-Output ""
foreach ($m in $type.members) {
Write-Output " [$($m.kind)] $($m.signature)"
}
return
}
Write-Error "Type not found: $FullName"
exit 1
}
# ─── Action: search ──────────────────────────────────────────────────────────
# Ranks namespaces by best match score on type names and member names.
# Outputs: ranked namespaces with top matching types and the JSON file path.
# The agent can then read the JSON file to inspect all members intelligently.
function Search-WinMd {
param([string]$SearchQuery, [int]$Max)
if (-not $SearchQuery) {
Write-Error "-Query is required for 'search' action."
exit 1
}
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
# Collect: namespace -> { bestScore, matchingTypes[], filePath }
$nsResults = @{}
foreach ($dir in $dirs) {
$nsFile = Join-Path $dir 'namespaces.json'
if (-not (Test-Path $nsFile)) { continue }
$nsList = Get-Content $nsFile -Raw | ConvertFrom-Json
foreach ($n in $nsList) {
$safeFile = $n.Replace('.', '_') + '.json'
$filePath = Join-Path $dir "types\$safeFile"
if (-not (Test-Path $filePath)) { continue }
$types = Get-Content $filePath -Raw | ConvertFrom-Json
foreach ($t in $types) {
$typeScore = Get-MatchScore -Name $t.name -FullName $t.fullName -Query $SearchQuery
# Also search member names for matches
$bestMemberScore = 0
$matchingMember = $null
if ($t.members) {
foreach ($m in $t.members) {
$memberName = $m.name
$mScore = Get-MatchScore -Name $memberName -FullName "$($t.fullName).$memberName" -Query $SearchQuery
if ($mScore -gt $bestMemberScore) {
$bestMemberScore = $mScore
$matchingMember = $m.signature
}
}
}
$score = [Math]::Max($typeScore, $bestMemberScore)
if ($score -le 0) { continue }
if (-not $nsResults.ContainsKey($n)) {
$nsResults[$n] = @{ BestScore = 0; Types = @(); FilePaths = @() }
}
$entry = $nsResults[$n]
if ($score -gt $entry.BestScore) { $entry.BestScore = $score }
if ($entry.FilePaths -notcontains $filePath) {
$entry.FilePaths += $filePath
}
if ($typeScore -ge $bestMemberScore) {
$entry.Types += @{ Text = "$($t.kind) $($t.fullName) [$typeScore]"; Score = $typeScore }
} else {
$entry.Types += @{ Text = "$($t.kind) $($t.fullName) -> $matchingMember [$bestMemberScore]"; Score = $bestMemberScore }
}
}
}
}
if ($nsResults.Count -eq 0) {
Write-Output "No results found for: $SearchQuery"
return
}
$ranked = $nsResults.GetEnumerator() |
Sort-Object { $_.Value.BestScore } -Descending |
Select-Object -First $Max
foreach ($r in $ranked) {
$ns = $r.Key
$info = $r.Value
Write-Output "[$($info.BestScore)] $ns"
foreach ($fp in $info.FilePaths) {
Write-Output " File: $fp"
}
# Show top 5 highest-scoring matching types in this namespace
$info.Types | Sort-Object { $_.Score } -Descending |
Select-Object -First 5 |
ForEach-Object { Write-Output " $($_.Text)" }
Write-Output ""
}
}
# ─── Search scoring ──────────────────────────────────────────────────────────
# Simple ranked scoring on type names. Higher = better.
# 100 = exact name 80 = starts-with 60 = substring
# 50 = PascalCase 40 = multi-keyword 20 = fuzzy subsequence
function Get-MatchScore {
param([string]$Name, [string]$FullName, [string]$Query)
$q = $Query.Trim()
if (-not $q) { return 0 }
if ($Name -eq $q) { return 100 }
if ($Name -like "$q*") { return 80 }
if ($Name -like "*$q*" -or $FullName -like "*$q*") { return 60 }
$initials = ($Name.ToCharArray() | Where-Object { [char]::IsUpper($_) }) -join ''
if ($initials.Length -ge 2 -and $initials -like "*$q*") { return 50 }
$words = $q -split '\s+' | Where-Object { $_.Length -gt 0 }
if ($words.Count -gt 1) {
$allFound = $true
foreach ($w in $words) {
if ($Name -notlike "*$w*" -and $FullName -notlike "*$w*") {
$allFound = $false
break
}
}
if ($allFound) { return 40 }
}
if (Test-FuzzySubsequence -Text $Name -Pattern $q) { return 20 }
return 0
}
function Test-FuzzySubsequence {
param([string]$Text, [string]$Pattern)
$ti = 0
$tLower = $Text.ToLowerInvariant()
$pLower = $Pattern.ToLowerInvariant()
foreach ($ch in $pLower.ToCharArray()) {
$idx = $tLower.IndexOf($ch, $ti)
if ($idx -lt 0) { return $false }
$ti = $idx + 1
}
return $true
}
# ─── Action: enums ───────────────────────────────────────────────────────────
function Get-EnumValues {
param([string]$FullName)
if (-not $FullName) {
Write-Error "-TypeName is required for 'enums' action."
exit 1
}
$lastDot = $FullName.LastIndexOf('.')
if ($lastDot -lt 1) {
Write-Error "-TypeName must be a fully-qualified type name including namespace, e.g. 'Namespace.TypeName'. Provided: $FullName"
exit 1
}
$ns = $FullName.Substring(0, $lastDot)
$safeFile = $ns.Replace('.', '_') + '.json'
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
foreach ($dir in $dirs) {
$filePath = Join-Path $dir "types\$safeFile"
if (-not (Test-Path $filePath)) { continue }
$types = Get-Content $filePath -Raw | ConvertFrom-Json
$type = $types | Where-Object { $_.fullName -eq $FullName }
if (-not $type) { continue }
if ($type.kind -ne 'Enum') {
Write-Error "$FullName is not an Enum (kind: $($type.kind))"
exit 1
}
Write-Output "Enum $($type.fullName)"
if ($type.enumValues) {
$type.enumValues | ForEach-Object { Write-Output " $_" }
} else {
Write-Output " (no values)"
}
return
}
Write-Error "Type not found: $FullName"
exit 1
}
# ─── Dispatch ─────────────────────────────────────────────────────────────────
switch ($Action) {
'projects' { Show-Projects }
'packages' { Show-Packages }
'stats' { Show-Stats }
'namespaces' { Get-Namespaces -Prefix $Filter }
'types' { Get-TypesInNamespace -Ns $Namespace }
'members' { Get-MembersOfType -FullName $TypeName }
'search' { Search-WinMd -SearchQuery $Query -Max $MaxResults }
'enums' { Get-EnumValues -FullName $TypeName }
}

View File

@@ -1,208 +0,0 @@
<#
.SYNOPSIS
Generate or refresh the WinMD cache for the Agent Skill.
.DESCRIPTION
Builds and runs the standalone cache generator to export cached JSON files
from all WinMD metadata found in project NuGet packages and Windows SDK.
The cache is per-package+version: if two projects reference the same
package at the same version, the WinMD data is parsed once and shared.
Supports single project or recursive scan of an entire repo.
.PARAMETER ProjectDir
Path to a project directory (contains .csproj/.vcxproj), or a project file itself.
Defaults to scanning the workspace root.
.PARAMETER Scan
Recursively discover all .csproj/.vcxproj files under ProjectDir.
.PARAMETER OutputDir
Path to the cache output directory. Defaults to "Generated Files\winmd-cache".
.EXAMPLE
.\Update-WinMdCache.ps1
.\Update-WinMdCache.ps1 -ProjectDir BlankWinUI
.\Update-WinMdCache.ps1 -Scan -ProjectDir .
.\Update-WinMdCache.ps1 -ProjectDir "src\MyApp\MyApp.csproj"
#>
[CmdletBinding()]
param(
[string]$ProjectDir,
[switch]$Scan,
[string]$OutputDir = 'Generated Files\winmd-cache'
)
$ErrorActionPreference = 'Stop'
# Convention: skill lives at .github/skills/winmd-api-search/scripts/
# so workspace root is 4 levels up from $PSScriptRoot.
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path
$generatorProj = Join-Path (Join-Path $PSScriptRoot 'cache-generator') 'CacheGenerator.csproj'
# ---------------------------------------------------------------------------
# WinAppSDK version detection -- look only at the repo root folder (no recursion)
# ---------------------------------------------------------------------------
function Get-WinAppSdkVersionFromDirectoryPackagesProps {
<#
.SYNOPSIS
Extract Microsoft.WindowsAppSDK version from a Directory.Packages.props
(Central Package Management) at the repo root.
#>
param([string]$RepoRoot)
$propsFile = Join-Path $RepoRoot 'Directory.Packages.props'
if (-not (Test-Path $propsFile)) { return $null }
try {
[xml]$xml = Get-Content $propsFile -Raw
$node = $xml.SelectNodes('//PackageVersion') |
Where-Object { $_.Include -eq 'Microsoft.WindowsAppSDK' } |
Select-Object -First 1
if ($node) { return $node.Version }
} catch {
Write-Verbose "Could not parse $propsFile : $_"
}
return $null
}
function Get-WinAppSdkVersionFromPackagesConfig {
<#
.SYNOPSIS
Extract Microsoft.WindowsAppSDK version from a packages.config at the repo root.
#>
param([string]$RepoRoot)
$configFile = Join-Path $RepoRoot 'packages.config'
if (-not (Test-Path $configFile)) { return $null }
try {
[xml]$xml = Get-Content $configFile -Raw
$node = $xml.SelectNodes('//package') |
Where-Object { $_.id -eq 'Microsoft.WindowsAppSDK' } |
Select-Object -First 1
if ($node) { return $node.version }
} catch {
Write-Verbose "Could not parse $configFile : $_"
}
return $null
}
# Try Directory.Packages.props first (CPM), then packages.config
$winAppSdkVersion = Get-WinAppSdkVersionFromDirectoryPackagesProps -RepoRoot $root
if (-not $winAppSdkVersion) {
$winAppSdkVersion = Get-WinAppSdkVersionFromPackagesConfig -RepoRoot $root
}
if ($winAppSdkVersion) {
Write-Host "Detected WinAppSDK version from repo: $winAppSdkVersion" -ForegroundColor Cyan
} else {
Write-Host "No WinAppSDK version found at repo root; will use latest (Version=*)" -ForegroundColor Yellow
}
# Default: if no ProjectDir, scan the workspace root
if (-not $ProjectDir) {
$ProjectDir = $root
$Scan = $true
}
Push-Location $root
try {
# Detect installed .NET SDK -- require >= 8.0, prefer stable over preview
$dotnetSdks = dotnet --list-sdks 2>$null
$bestMajor = $dotnetSdks |
Where-Object { $_ -notmatch 'preview|rc|alpha|beta' } |
ForEach-Object { if ($_ -match '^(\d+)\.') { [int]$Matches[1] } } |
Where-Object { $_ -ge 8 } |
Sort-Object -Descending |
Select-Object -First 1
# Fall back to preview SDKs if no stable SDK found
if (-not $bestMajor) {
$bestMajor = $dotnetSdks |
ForEach-Object { if ($_ -match '^(\d+)\.') { [int]$Matches[1] } } |
Where-Object { $_ -ge 8 } |
Sort-Object -Descending |
Select-Object -First 1
}
if (-not $bestMajor) {
Write-Error "No .NET SDK >= 8.0 found. Install from https://dotnet.microsoft.com/download"
exit 1
}
$targetFramework = "net$bestMajor.0"
Write-Host "Using .NET SDK: $targetFramework" -ForegroundColor Cyan
# Build MSBuild properties -- pass detected WinAppSDK version when available
$sdkVersionProp = ''
if ($winAppSdkVersion) {
$sdkVersionProp = "-p:WinAppSdkVersion=$winAppSdkVersion"
}
Write-Host "Building cache generator..." -ForegroundColor Cyan
$restoreArgs = @($generatorProj, "-p:TargetFramework=$targetFramework", '--nologo', '-v', 'q')
if ($sdkVersionProp) { $restoreArgs += $sdkVersionProp }
dotnet restore @restoreArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Restore failed"
exit 1
}
$buildArgs = @($generatorProj, '-c', 'Release', '--nologo', '-v', 'q', "-p:TargetFramework=$targetFramework", '--no-restore')
if ($sdkVersionProp) { $buildArgs += $sdkVersionProp }
dotnet build @buildArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Build failed"
exit 1
}
# Run the built executable directly (avoids dotnet run target framework mismatch issues)
$generatorDir = Join-Path $PSScriptRoot 'cache-generator'
$exePath = Join-Path $generatorDir "bin\Release\$targetFramework\CacheGenerator.exe"
if (-not (Test-Path $exePath)) {
# Fallback: try dll with dotnet
$dllPath = Join-Path $generatorDir "bin\Release\$targetFramework\CacheGenerator.dll"
if (Test-Path $dllPath) {
$exePath = $null
} else {
Write-Error "Built executable not found at: $exePath"
exit 1
}
}
$runArgs = @()
if ($Scan) {
$runArgs += '--scan'
}
# Detect installed WinAppSDK runtime via Get-AppxPackage (the WindowsApps
# folder is ACL-restricted so C# cannot enumerate it directly).
# WinMD files are architecture-independent metadata, so pick whichever arch
# matches the current OS to ensure the package is present.
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
$runtimePkg = Get-AppxPackage -Name 'Microsoft.WindowsAppRuntime.*' -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notmatch 'CBS' -and $_.Architecture -eq $osArch } |
Sort-Object -Property Version -Descending |
Select-Object -First 1
if ($runtimePkg -and $runtimePkg.InstallLocation -and (Test-Path $runtimePkg.InstallLocation)) {
Write-Host "Detected WinAppSDK runtime: $($runtimePkg.Name) v$($runtimePkg.Version)" -ForegroundColor Cyan
$runArgs += '--winappsdk-runtime'
$runArgs += $runtimePkg.InstallLocation
}
$runArgs += $ProjectDir
$runArgs += $OutputDir
Write-Host "Exporting WinMD cache..." -ForegroundColor Cyan
if ($exePath) {
& $exePath @runArgs
} else {
dotnet $dllPath @runArgs
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Cache export failed"
exit 1
}
Write-Host "Cache updated at: $OutputDir" -ForegroundColor Green
} finally {
Pop-Location
}

View File

@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Default fallback; Update-WinMdCache.ps1 overrides via -p:TargetFramework=net{X}.0 -->
<TargetFramework Condition="'$(TargetFramework)' == ''">net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<!-- System.Reflection.Metadata is inbox in net9.0+, only needed for net8.0 -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.Reflection.Metadata" Version="8.0.1" />
</ItemGroup>
<!--
Baseline WinAppSDK packages: downloaded during restore so the cache generator
can always index WinAppSDK APIs, even if the target project hasn't been restored.
ExcludeAssets="all" means they're downloaded but don't affect this tool's build.
When the repo has a known version (passed via -p:WinAppSdkVersion=X.Y.Z from
Update-WinMdCache.ps1), prefer that version to avoid unnecessary NuGet downloads.
Falls back to Version="*" (latest) on fresh clones with no restore.
-->
<ItemGroup Condition="'$(WinAppSdkVersion)' != ''">
<PackageReference Include="Microsoft.WindowsAppSDK" Version="$(WinAppSdkVersion)" ExcludeAssets="all" />
</ItemGroup>
<ItemGroup Condition="'$(WinAppSdkVersion)' == ''">
<PackageReference Include="Microsoft.WindowsAppSDK" Version="*" ExcludeAssets="all" />
</ItemGroup>
</Project>

View File

@@ -1,3 +0,0 @@
<Project>
<!-- Isolate this standalone tool from the repo-level build configuration -->
</Project>

View File

@@ -1,3 +0,0 @@
<Project>
<!-- Isolate this standalone tool from the repo-level build targets -->
</Project>

View File

@@ -1,3 +0,0 @@
<Project>
<!-- Isolate this standalone tool from the repo-level Central Package Management -->
</Project>

File diff suppressed because it is too large Load Diff

View File

@@ -1,304 +0,0 @@
---
name: wpf-to-winui3-migration
description: 'Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.'
license: Complete terms in LICENSE.txt
---
# WPF to WinUI 3 Migration Skill
Migrate PowerToys modules from WPF (`System.Windows.*`) to WinUI 3 (`Microsoft.UI.Xaml.*` / Windows App SDK). Based on patterns validated in the ImageResizer module migration.
## When to Use This Skill
- Migrate a PowerToys module from WPF to WinUI 3
- Convert WPF XAML files to WinUI 3 XAML
- Replace `System.Windows` namespaces with `Microsoft.UI.Xaml`
- Migrate `Dispatcher` usage to `DispatcherQueue`
- Migrate custom `Observable`/`RelayCommand` to CommunityToolkit.Mvvm source generators
- Replace WPF-UI (Lepo) controls with native WinUI 3 controls
- Convert imaging code from `System.Windows.Media.Imaging` to `Windows.Graphics.Imaging`
- Handle WPF `Window` vs WinUI `Window` differences (sizing, positioning, SizeToContent)
- Migrate resource files from `.resx` to `.resw` with `ResourceLoader`
- Fix installer/build pipeline issues after WinUI 3 migration
- Update project files, NuGet packages, and signing config
## Prerequisites
- Visual Studio 2022 17.4+
- Windows App SDK NuGet package (`Microsoft.WindowsAppSDK`)
- .NET 8+ with `net8.0-windows10.0.19041.0` TFM
- Windows 10 1803+ (April 2018 Update or newer)
## Migration Strategy
### Phase-by-Phase Scope
Work on bounded problems, not the entire codebase at once. Each phase should compile before moving to the next.
1. **Project file** — Update TFM, NuGet packages, set `<UseWinUI>true</UseWinUI>`
2. **Data models and business logic** — No UI dependencies, migrate first
3. **MVVM framework** — Replace custom Observable/RelayCommand with CommunityToolkit.Mvvm
4. **Resource strings** — Migrate `.resx``.resw`, introduce `ResourceLoaderInstance`
5. **Services and utilities** — Replace `System.Windows` types, async-ify imaging code
6. **ViewModels** — Update Dispatcher usage, binding patterns
7. **Views/Pages** — Starting from leaf pages with fewest dependencies
8. **Main page / shell** — Last, since it depends on everything
9. **App.xaml / startup code** — Merge carefully (do NOT overwrite WinUI 3 boilerplate)
10. **Installer & build pipeline** — Update WiX, signing, build events
11. **Tests** — Adapt for WinUI 3 runtime, async patterns
### Migration Contract: Prohibited Patterns
These rules capture human judgment and must be applied consistently across every file. Do NOT deviate.
**Architecture prohibitions:**
- **Do NOT overwrite `App.xaml` / `App.xaml.cs`** — WinUI 3 has different lifecycle boilerplate. Merge resources and init code into the generated WinUI 3 App class.
- **Do NOT create Exe→WinExe `ProjectReference`** — Extract shared code to a Library project. Causes phantom build artifacts.
- **Do NOT instantiate services directly** — Use DI and CommunityToolkit.Mvvm patterns.
- **Do NOT create a `Window` subclass for every dialog or sub-page** — use `ContentDialog` for in-app dialogs and `Frame`/`Page` navigation for sub-views. Separate `Window` classes are reserved for distinct top-level surfaces (e.g., FancyZones editor, OOBE).
- **Do NOT omit `WindowsPackageType=None` and `WindowsAppSDKSelfContained=true`** — Both are mandatory in the csproj for every WinUI 3 module in PowerToys. Without them the app crashes at startup with `COMException: ClassFactory cannot supply requested class` because the WinUI 3 runtime DLLs are not found.
**XAML prohibitions:**
- **Do NOT use `{DynamicResource}`** — Replace with `{ThemeResource}` (theme-reactive) or `{StaticResource}`.
- **Do NOT use `{Binding}` in `Setter.Value`** — Not supported in WinUI 3. Use `{StaticResource}`.
- **Do NOT use `{x:Static}`** — Replace with `{x:Bind}`, `x:Uid`, or code-behind.
- **Do NOT use `{x:Type}`** — Not supported. Use `x:DataType` for DataTemplate, or code-behind.
- **Do NOT use `clr-namespace:`** — Replace with `using:` in all xmlns declarations.
- **Do NOT use `Style.Triggers` / `DataTrigger` / `EventTrigger`** — Replace with `VisualStateManager`.
- **Do NOT use `MultiBinding`** — Replace with `x:Bind` function binding or computed ViewModel property.
- **Do NOT use `Visibility="Hidden"`** — WinUI only has `Visible` and `Collapsed`. Use `Opacity="0"` if layout must be preserved.
- **Do NOT use `IsDefault` / `IsCancel`** — Use `AccentButtonStyle` for primary button; handle Enter/Escape in code-behind.
- **Do NOT omit `BasedOn` when overriding default styles** — Without it, your style replaces the entire default. Always use `BasedOn="{StaticResource DefaultButtonStyle}"` etc.
- **Do NOT omit `XamlControlsResources` as first merged dictionary** — It provides default Fluent styles. Without it, controls have no visual appearance.
**Code-behind prohibitions:**
- **Do NOT use `Application.Current.Dispatcher`** — Store `DispatcherQueue` in a static field explicitly.
- **Do NOT use `Window.Current`** — Not supported. Use a custom `App.Window` static property.
- **Do NOT put `DataContext`, `Resources`, or `VisualStateManager` on `Window`** — WinUI 3 `Window` is NOT a `DependencyObject`. Use a root `Page`/`UserControl`/`Grid`.
- **Do NOT use tunneling/preview events** (`PreviewMouseDown`, `PreviewKeyDown`) — WinUI has no tunneling. Use bubbling equivalents with `Handled` property or `AddHandler(handledEventsToo: true)`.
**Resource prohibitions:**
- **Do NOT use `Properties.Resources.MyString`** — Replace with `ResourceLoaderInstance.ResourceLoader.GetString("MyString")`.
- **Do NOT initialize `ResourceLoader`-dependent values as static fields** — Wrap in `Lazy<T>` or null-coalescing property.
- **Do NOT use `pack://` URIs** — Replace with `ms-appx:///` scheme.
## Quick Reference Tables
### Namespace Mapping
| WPF | WinUI 3 | Notes |
|-----|---------|-------|
| `System.Windows` | `Microsoft.UI.Xaml` | Root namespace |
| `System.Windows.Controls` | `Microsoft.UI.Xaml.Controls` | Core controls |
| `System.Windows.Controls.Primitives` | `Microsoft.UI.Xaml.Controls.Primitives` | Low-level primitives |
| `System.Windows.Media` | `Microsoft.UI.Xaml.Media` | Brushes, transforms |
| `System.Windows.Media.Animation` | `Microsoft.UI.Xaml.Media.Animation` | Storyboard, animations |
| `System.Windows.Media.Imaging` | `Microsoft.UI.Xaml.Media.Imaging` (UI) / `Windows.Graphics.Imaging` (processing) | Split by purpose |
| `System.Windows.Media.Media3D` | **No equivalent** | Use Win2D or Composition APIs |
| `System.Windows.Shapes` | `Microsoft.UI.Xaml.Shapes` | Rectangle, Ellipse, Path |
| `System.Windows.Input` | `Microsoft.UI.Xaml.Input` | Pointer, keyboard, focus |
| `System.Windows.Data` | `Microsoft.UI.Xaml.Data` | Binding, IValueConverter |
| `System.Windows.Documents` | `Microsoft.UI.Xaml.Documents` | Limited — RichTextBlock + Paragraph |
| `System.Windows.Markup` | `Microsoft.UI.Xaml.Markup` | XAML parsing, markup extensions |
| `System.Windows.Automation` | `Microsoft.UI.Xaml.Automation` | Accessibility / UI Automation |
| `System.Windows.Navigation` | **No direct equivalent** | Use `Frame.Navigate()` |
| `System.Windows.Threading` | `Microsoft.UI.Dispatching` | Dispatcher → DispatcherQueue |
| `System.Windows.Interop` | `WinRT.Interop` / `Microsoft.UI.Xaml.Hosting` | HWND interop |
### Control Replacements (No 1:1 Mapping)
These WPF controls have no direct counterpart and require a different control or third-party package:
| WPF Control | WinUI 3 Replacement | Notes |
|-------------|---------------------|-------|
| `DataGrid` | [`WinUI.TableView`](https://github.com/w-ahmad/WinUI.TableView) | Community library; the Toolkit `DataGrid` is no longer maintained. Legacy code may still pin v7 `CommunityToolkit.WinUI.UI.Controls.DataGrid` 7.1.2 |
| `Ribbon` | `CommandBar` / `NavigationView`, or [Toolkit Labs Ribbon](https://github.com/CommunityToolkit/Labs-Windows/tree/main/components/Ribbon) | No first-party Ribbon in WinUI; Labs component is experimental/partial |
| `Menu` / `MenuItem` | `MenuBar` / `MenuBarItem` / `MenuFlyout` | `MenuBar` for classic menu, `MenuFlyout` for context |
| `ContextMenu` | `MenuFlyout` | Assign to `ContextFlyout` property |
| `ToolBar` / `ToolBarTray` | `CommandBar` + `AppBarButton` | |
| `StatusBar` | Custom `Grid`/`StackPanel` or `InfoBar` | No StatusBar control |
| `TabControl` | `TabView` or `NavigationView` (top mode) | `TabView` for closeable tabs |
| `DocumentViewer` | `WebView2` | Render PDFs/XPS inside WebView2 |
| `FlowDocument` | `RichTextBlock` | Partial replacement only |
| `RichTextBox` | `RichEditBox` | Rich text editing |
| `GroupBox` | `Expander` (built-in) or `HeaderedContentControl` (Toolkit) | See [Layout & Header Controls from CommunityToolkit.WinUI](#layout--header-controls-from-communitytoolkitwinui) below |
| `Label` | `TextBlock` | WPF `Label` is a `ContentControl`; use `TextBlock` + `AccessKey` |
| `TreeView` | `TreeView` (native) | Available natively, but data binding model differs significantly |
| `MessageBox` | `ContentDialog` | Must set `XamlRoot` before `ShowAsync()` |
| `MediaElement` | `MediaPlayerElement` | Different API |
| `AccessText` | Not available | Use `AccessKey` property on target control |
### Layout & Header Controls from CommunityToolkit.WinUI
These WPF controls have no built-in WinUI 3 equivalent — install the corresponding CommunityToolkit package. **The NuGet package id and the XAML namespace differ intentionally**: package names end in `.Primitives` / `.HeaderedControls`, but the registered XAML namespace is the shorter `CommunityToolkit.WinUI.Controls` (confirmed in the [official Microsoft Q&A](https://learn.microsoft.com/en-us/answers/questions/5746230/why-does-communitytoolkit-uwp-controls-primitive-u)).
| WPF Control | WinUI 3 Replacement | NuGet Package | XAML Namespace |
|-------------|---------------------|---------------|----------------|
| `WrapPanel` | `WrapPanel` | `CommunityToolkit.WinUI.Controls.Primitives` | `using:CommunityToolkit.WinUI.Controls` |
| `UniformGrid` | `UniformGrid` | `CommunityToolkit.WinUI.Controls.Primitives` | `using:CommunityToolkit.WinUI.Controls` |
| `DockPanel` | `DockPanel` | `CommunityToolkit.WinUI.Controls.Primitives` | `using:CommunityToolkit.WinUI.Controls` |
| `GroupBox` (alt.) | `HeaderedContentControl` | `CommunityToolkit.WinUI.Controls.HeaderedControls` | `using:CommunityToolkit.WinUI.Controls` |
### No Equivalent — Requires Architectural Rework
These WPF features have no WinUI counterpart and require redesign, not find-and-replace:
| WPF Feature | WinUI 3 Replacement Strategy |
|-------------|------------------------------|
| `Style.Triggers` / `DataTrigger` | `VisualStateManager` with `StateTrigger` — see [XAML Migration](./references/xaml-migration.md) |
| `MultiBinding` | `x:Bind` function binding: `{x:Bind local:Converters.Format(VM.A, VM.B), Mode=OneWay}` |
| `RoutedUICommand` / `CommandBinding` | `ICommand` / `[RelayCommand]` from CommunityToolkit.Mvvm. WinUI also has `StandardUICommand` / `XamlUICommand` for platform commands. |
| `AdornerLayer` / `Adorner` | Depends on use case: `TeachingTip`/`InfoBar` (validation), `Popup` (overlays), `PlaceholderText` (watermarks), Canvas overlay (decorations) |
| `Visibility.Hidden` | `Opacity="0"` with `Visibility="Visible"` (preserves layout space) |
| `Window.Resources` / `Window.DataContext` | Move to root `Grid.Resources` / root `Page`/`UserControl` — WinUI `Window` is NOT a DependencyObject |
| Tunneling events (`Preview*`) | Use bubbling equivalents + `Handled` property or `AddHandler(handledEventsToo: true)` |
### Critical API Replacements
| WPF | WinUI 3 | Notes |
|-----|---------|-------|
| `Dispatcher.Invoke()` | `DispatcherQueue.TryEnqueue()` | Different return type (`bool`), async by default |
| `Dispatcher.CheckAccess()` | `DispatcherQueue.HasThreadAccess` | Property vs method |
| `Application.Current.Dispatcher` | Store `DispatcherQueue` in static field | See [Threading](./references/threading-and-windowing.md) |
| `Window.Current` | Custom `App.Window` static property | Not supported in Windows App SDK |
| `Application.Current.MainWindow` | Custom `App.Window` static property | Must track manually |
| `MessageBox.Show()` | `ContentDialog` | Must set `XamlRoot` |
| `System.Windows.Clipboard` | `Windows.ApplicationModel.DataTransfer.Clipboard` | Different API surface |
| `RoutedUICommand` / `CommandBinding` | `ICommand` / `[RelayCommand]` | Remove `CommandBinding`; bind `ICommand` directly |
| `Properties.Resources.MyString` | `ResourceLoaderInstance.ResourceLoader.GetString("MyString")` | Lazy-init pattern |
| `DynamicResource` | `ThemeResource` | Theme-reactive only |
| `clr-namespace:` | `using:` | XAML namespace prefix |
| `{x:Static props:Resources.Key}` | `x:Uid` or `ResourceLoader.GetString()` | .resx → .resw |
| `DataType="{x:Type m:Foo}"` | `x:DataType="m:Foo"` | `x:Type` not supported |
| `SizeToContent="Height"` | Custom `SizeToContent()` via `AppWindow.Resize()` | See [Windowing](./references/threading-and-windowing.md) |
| `Pack URI (pack://...)` | `ms-appx:///` | Resource URI scheme |
| `Observable` (custom base) | `ObservableObject` + `[ObservableProperty]` | CommunityToolkit.Mvvm |
| `RelayCommand` (custom) | `[RelayCommand]` source generator | CommunityToolkit.Mvvm |
| `JpegBitmapEncoder` | `BitmapEncoder.CreateAsync(JpegEncoderId, stream)` | Async, unified API |
| `encoder.QualityLevel = 85` | `BitmapPropertySet { "ImageQuality", 0.85f }` | int 1-100 → float 0-1 |
### Event Replacements (Mouse → Pointer)
| WPF Event | WinUI 3 Event | Notes |
|-----------|--------------|-------|
| `MouseLeftButtonDown` | `PointerPressed` | Check `IsLeftButtonPressed` on args |
| `MouseLeftButtonUp` | `PointerReleased` | Check pointer properties |
| `MouseRightButtonDown` | `RightTapped` | Or `PointerPressed` with right button check |
| `MouseMove` | `PointerMoved` | `MouseEventArgs``PointerRoutedEventArgs` |
| `MouseWheel` | `PointerWheelChanged` | Different event args |
| `MouseEnter` / `MouseLeave` | `PointerEntered` / `PointerExited` | |
| `MouseDoubleClick` | `DoubleTapped` | Different event args |
| `PreviewMouseDown` | `PointerPressed` | No tunneling — use `Handled` or `AddHandler` |
| `PreviewKeyDown` | `KeyDown` | `KeyEventArgs``KeyRoutedEventArgs` |
### Property Replacements
| WPF | WinUI 3 | Context |
|-----|---------|---------|
| `Visibility.Hidden` | `Visibility.Collapsed` or `Opacity="0"` | Use `Opacity="0"` to preserve layout |
| `TextWrapping.WrapWithOverflow` | `TextWrapping.Wrap` | WinUI doesn't distinguish |
| `Focusable="True"` | `IsTabStop="True"` | Different property name |
| `ContextMenu=` | `ContextFlyout=` | On any `UIElement` |
| `MediaElement` | `MediaPlayerElement` | Different API |
| `SnapsToDevicePixels` | Not available | WinUI handles pixel snapping internally |
### NuGet Package Migration
| WPF | WinUI 3 | Notes |
|-----|---------|-------|
| `Microsoft.Xaml.Behaviors.Wpf` | `Microsoft.Xaml.Behaviors.WinUI.Managed` | |
| `WPF-UI` (Lepo) | **Remove** — use native WinUI 3 controls | |
| `CommunityToolkit.Mvvm` | `CommunityToolkit.Mvvm` (same) | |
| `Microsoft.Toolkit.Wpf.*` | `CommunityToolkit.WinUI.*` | |
| (none) | `Microsoft.WindowsAppSDK` | Required |
| (none) | `Microsoft.Windows.SDK.BuildTools` | Required |
| (none) | `WinUIEx` | Optional, window helpers |
| (none) | `CommunityToolkit.WinUI.Converters` | Optional |
| (none) | `CommunityToolkit.WinUI.Controls.Primitives` | Optional — `WrapPanel`, `UniformGrid`, `DockPanel`, `ConstrainedBox` |
| (none) | `CommunityToolkit.WinUI.Controls.HeaderedControls` | Optional — `HeaderedContentControl`, `HeaderedItemsControl`, `HeaderedTreeView` |
| (none) | `CommunityToolkit.WinUI.Controls.SettingsControls` | Optional — `SettingsCard`, `SettingsExpander` |
| (none) | `CommunityToolkit.WinUI.Controls.Sizers` | Optional — `GridSplitter` |
| (none) | `CommunityToolkit.WinUI.UI.Controls.DataGrid` | Legacy v7 — only for migrating existing `DataGrid` code; prefer `WinUI.TableView` |
### XAML Syntax Changes
| WPF | WinUI 3 | Notes |
|-----|---------|-------|
| `xmlns:local="clr-namespace:MyApp"` | `xmlns:local="using:MyApp"` | CLR → using syntax |
| `{DynamicResource Key}` | `{ThemeResource Key}` | Re-evaluates on theme change |
| `{StaticResource Key}` | `{StaticResource Key}` | Same — resolved once at load |
| `{x:Static Type.Member}` | `{x:Bind}` or code-behind | |
| `{x:Type local:MyType}` | Not supported | Use `x:DataType` for DataTemplate |
| `{x:Array}` | Not supported | Create collections in code-behind |
| `<Style.Triggers>` / `<DataTrigger>` | `VisualStateManager` | See [XAML Migration](./references/xaml-migration.md) |
| `{Binding}` in `Setter.Value` | Not supported — use `StaticResource` | |
| `Content="{x:Static p:Resources.Cancel}"` | `x:Uid="Cancel"` with `.Content` in `.resw` | |
| `sys:String` / `sys:Int32` / etc. | `x:String` / `x:Int32` / etc. | XAML intrinsic types |
| `<ui:FluentWindow>` (WPF-UI) | `<Window>` | Native + `ExtendsContentIntoTitleBar` |
| `<ui:NumberBox>` / `<ui:ProgressRing>` (WPF-UI) | Native `<NumberBox>` / `<ProgressRing>` | |
| `BasedOn="{StaticResource {x:Type ui:Button}}"` | `BasedOn="{StaticResource DefaultButtonStyle}"` | Named style keys |
| `IsDefault="True"` / `IsCancel="True"` | `Style="{StaticResource AccentButtonStyle}"` / KeyDown | |
| `<AccessText>` | Not available — use `AccessKey` property | |
| `<behaviors:Interaction.Triggers>` | Code-behind or WinUI behaviors | |
| `Window.Resources` | Root container's `Resources` (e.g. `Grid.Resources`) | Window is not a DependencyObject |
### Binding: {Binding} vs {x:Bind}
Both work in WinUI 3. Prefer `{x:Bind}` for new/migrated code.
| Feature | `{Binding}` | `{x:Bind}` |
|---------|------------|------------|
| Default mode | `OneWay` | **`OneTime`** — add `Mode=OneWay` explicitly! |
| Default source | `DataContext` | Page/UserControl code-behind |
| Compile-time validation | No | Yes |
| Function binding | No | Yes (replaces `MultiBinding`) |
| Performance | Reflection-based | Compiled, no reflection |
| `MultiBinding` support | No (not in WinUI) | Use function binding |
## Detailed Reference Docs
Read only the section relevant to your current task:
- [Namespace and API Mapping](./references/namespace-api-mapping.md) — Full type mapping, NuGet changes, project file, CsWinRT interop
- [XAML Migration Guide](./references/xaml-migration.md) — XAML syntax, WPF-UI removal, markup extensions, styles, resources, data binding
- [Threading and Window Management](./references/threading-and-windowing.md) — Dispatcher, DispatcherQueue, SizeToContent, AppWindow, HWND interop, custom entry point
- [Imaging API Migration](./references/imaging-migration.md) — BitmapEncoder/Decoder, SoftwareBitmap, CodecHelper, async patterns, int→uint
- [PowerToys-Specific Patterns](./references/powertoys-patterns.md) — MVVM migration, ResourceLoader, Lazy init, installer, signing, test adaptation, build pipeline
## Common Pitfalls (from ImageResizer migration)
| Pitfall | Solution |
|---------|----------|
| `ContentDialog` throws "does not have a XamlRoot" | Set `dialog.XamlRoot = this.Content.XamlRoot` before `ShowAsync()` |
| `FilePicker` throws error in desktop app | Call `WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd)` |
| `Window.Dispatcher` returns null | Use `Window.DispatcherQueue` instead |
| Resources on `Window` element not found | Move resources to root layout container (`Grid.Resources`) |
| `VisualStateManager` on `Window` fails | Use `UserControl` or `Page` inside the Window |
| Satellite assembly installer errors (`WIX0103`) | Remove `.resources.dll` refs from `Resources.wxs`; WinUI 3 uses `.pri` |
| Phantom `.exe`/`.deps.json` in root output dir | Avoid Exe→WinExe `ProjectReference`; use Library project |
| `ResourceLoader` crash at static init | Wrap in `Lazy<T>` or null-coalescing property — see [Lazy Init](./references/powertoys-patterns.md#lazy-initialization-for-resource-dependent-statics) |
| `SizeToContent` not available | Implement manual content measurement + `AppWindow.Resize()` with DPI scaling |
| `x:Bind` default mode is `OneTime` | Explicitly set `Mode=OneWay` or `Mode=TwoWay` |
| `DynamicResource` / `x:Static` not compiling | Replace with `ThemeResource` / `ResourceLoader` or `x:Uid` |
| `IValueConverter.Convert` signature mismatch | Last param: `CultureInfo``string` (language tag) |
| Test project can't resolve WPF types | Add `<UseWPF>true</UseWPF>` temporarily; remove after imaging migration |
| Pixel dimension type mismatch (`int` vs `uint`) | WinRT uses `uint` for pixel sizes — add `u` suffix in test assertions |
| `$(SolutionDir)` empty in standalone project build | Use `$(MSBuildThisFileDirectory)` with relative paths instead |
| JPEG quality value wrong after migration | WPF: int 1-100; WinRT: float 0.0-1.0 |
| MSIX packaging fails in PreBuildEvent | Move to PostBuildEvent; artifacts not ready at PreBuild time |
| RC file icon path with forward slashes | Use double-backslash escaping: `..\\ui\\Assets\\icon.ico` |
| `COMException: ClassFactory cannot supply requested class` at startup | Missing `<WindowsPackageType>None</WindowsPackageType>` and/or `<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>` in csproj. Without these, the app tries to locate the Windows App SDK framework package (not installed) instead of using bundled runtime DLLs. **Both properties are mandatory for every WinUI 3 module in PowerToys.** |
| `CombinedGeometry` not available in WinUI 3 | WinUI 3 `UIElement.Clip` only accepts `RectangleGeometry`. For overlay hole effects (exclude region), use a `Path` element with `GeometryGroup FillRule="EvenOdd"` containing two `RectangleGeometry` children — the EvenOdd rule creates a transparent hole where geometries overlap. |
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Build fails after namespace rename | Check for lingering `System.Windows` usings; some types have no direct equivalent |
| Missing `PresentationCore.dll` at runtime | Ensure ALL imaging code uses `Windows.Graphics.Imaging`, not `System.Windows.Media.Imaging` |
| `DataContext` not working on Window | WinUI 3 `Window` is not a `DependencyObject`; use a root `Page` or `UserControl` |
| XAML designer not available | WinUI 3 does not support XAML Designer; use Hot Reload instead |
| NuGet restore failures | Run `build-essentials.cmd` after adding `Microsoft.WindowsAppSDK` package |
| `Parallel.ForEach` compilation error | Migrate to `Parallel.ForEachAsync` for async imaging operations |
| Signing check fails on leaked artifacts | Run `generateAllFileComponents.ps1`; verify only `WinUI3Apps\\` paths in signing config |
| `COMException` / `ClassFactory` error at app launch | Ensure csproj has `<WindowsPackageType>None</WindowsPackageType>` and `<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>`. These are required for all unpackaged WinUI 3 apps in PowerToys — without them the WinUI 3 COM runtime cannot be found. |

View File

@@ -1,287 +0,0 @@
# Imaging API Migration
Migrating from WPF (`System.Windows.Media.Imaging` / `PresentationCore.dll`) to WinRT (`Windows.Graphics.Imaging`). Based on the ImageResizer migration.
## Why This Migration Is Required
WinUI 3 apps deployed as self-contained do NOT include `PresentationCore.dll`. Any code using `System.Windows.Media.Imaging` will throw `FileNotFoundException` at runtime. ALL imaging code must use WinRT APIs.
| Purpose | Namespace |
|---------|-----------|
| UI display (`Image.Source`) | `Microsoft.UI.Xaml.Media.Imaging` |
| Image processing (encode/decode/transform) | `Windows.Graphics.Imaging` |
## Architecture Change: Pipeline vs Declarative
The fundamental architecture differs:
**WPF**: In-memory pipeline of bitmap objects. Decode → transform → encode synchronously.
```csharp
var decoder = BitmapDecoder.Create(stream, ...);
var transform = new TransformedBitmap(decoder.Frames[0], new ScaleTransform(...));
var encoder = new JpegBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(transform, ...));
encoder.Save(outputStream);
```
**WinRT**: Declarative transform model. Configure transforms on the encoder, which handles pixel manipulation internally. All async.
```csharp
var decoder = await BitmapDecoder.CreateAsync(winrtStream);
var encoder = await BitmapEncoder.CreateForTranscodingAsync(outputStream, decoder);
encoder.BitmapTransform.ScaledWidth = newWidth;
encoder.BitmapTransform.ScaledHeight = newHeight;
encoder.BitmapTransform.InterpolationMode = BitmapInterpolationMode.Fant;
await encoder.FlushAsync();
```
## Core Type Mapping
### Decoders
| WPF | WinRT | Notes |
|-----|-------|-------|
| `BitmapDecoder.Create(stream, options, cache)` | `BitmapDecoder.CreateAsync(stream)` | Async, auto-detects format |
| `JpegBitmapDecoder` / `PngBitmapDecoder` / etc. | `BitmapDecoder.CreateAsync(stream)` | Single unified decoder |
| `decoder.Frames[0]` | `await decoder.GetFrameAsync(0)` | Async frame access |
| `decoder.Frames.Count` | `decoder.FrameCount` (uint) | `int``uint` |
| `decoder.CodecInfo.ContainerFormat` | `decoder.DecoderInformation.CodecId` | Different property path |
| `decoder.Frames[0].PixelWidth` (int) | `decoder.PixelWidth` (uint) | `int``uint` |
| `WmpBitmapDecoder` | Not available | WMP/HDP not supported |
### Encoders
| WPF | WinRT | Notes |
|-----|-------|-------|
| `new JpegBitmapEncoder()` | `BitmapEncoder.CreateAsync(BitmapEncoder.JpegEncoderId, stream)` | Async factory |
| `new PngBitmapEncoder()` | `BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream)` | No interlace control |
| `encoder.Frames.Add(frame)` | `encoder.SetSoftwareBitmap(bitmap)` | Different API |
| `encoder.Save(stream)` | `await encoder.FlushAsync()` | Async |
### Encoder Properties (Strongly-Typed → BitmapPropertySet)
WPF had type-specific encoder subclasses. WinRT uses a generic property set:
```csharp
// WPF
case JpegBitmapEncoder jpeg: jpeg.QualityLevel = 85; // int 1-100
case PngBitmapEncoder png: png.Interlace = PngInterlaceOption.On;
case TiffBitmapEncoder tiff: tiff.Compression = TiffCompressOption.Lzw;
// WinRT — JPEG quality (float 0.0-1.0)
await encoder.BitmapProperties.SetPropertiesAsync(new BitmapPropertySet
{
{ "ImageQuality", new BitmapTypedValue(0.85f, PropertyType.Single) }
});
// WinRT — TIFF compression (via BitmapPropertySet at creation time)
var props = new BitmapPropertySet
{
{ "TiffCompressionMethod", new BitmapTypedValue((byte)2, PropertyType.UInt8) }
};
var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.TiffEncoderId, stream, props);
```
**JPEG quality scale change**: WPF int `1-100` → WinRT float `0.0-1.0`. Divide by 100.
### Bitmap Types
| WPF | WinRT | Notes |
|-----|-------|-------|
| `BitmapSource` | `SoftwareBitmap` | Central pixel-data type |
| `BitmapImage` | `BitmapImage` (in `Microsoft.UI.Xaml.Media.Imaging`) | UI display only |
| `FormatConvertedBitmap` | `SoftwareBitmap.Convert()` | |
| `TransformedBitmap` + `ScaleTransform` | `BitmapTransform` via encoder | Declarative |
| `CroppedBitmap` | `BitmapTransform.Bounds` | |
### Metadata
| WPF | WinRT | Notes |
|-----|-------|-------|
| `BitmapMetadata` | `BitmapProperties` | Different API surface |
| `BitmapMetadata.Clone()` | No equivalent | Cannot selectively clone |
| Selective metadata removal | Not supported | All-or-nothing only |
**Two encoder creation strategies for metadata:**
- `CreateForTranscodingAsync()` — preserves ALL metadata from source
- `CreateAsync()` — creates fresh encoder with NO metadata
This eliminated ~258 lines of manual metadata manipulation code (`BitmapMetadataExtension.cs`) in ImageResizer.
### Interpolation Modes
| WPF `BitmapScalingMode` | WinRT `BitmapInterpolationMode` |
|------------------------|-------------------------------|
| `HighQuality` / `Fant` | `Fant` |
| `Linear` | `Linear` |
| `NearestNeighbor` | `NearestNeighbor` |
| `Unspecified` / `LowQuality` | `Linear` |
## Stream Interop
WinRT imaging requires `IRandomAccessStream` instead of `System.IO.Stream`:
```csharp
using var stream = File.OpenRead(path);
var winrtStream = stream.AsRandomAccessStream(); // Extension method
var decoder = await BitmapDecoder.CreateAsync(winrtStream);
```
**Critical**: For transcode, seek the input stream back to 0 before creating the encoder:
```csharp
winrtStream.Seek(0);
var encoder = await BitmapEncoder.CreateForTranscodingAsync(outputStream, decoder);
```
## CodecHelper Pattern (from ImageResizer)
WPF stored container format GUIDs in `settings.json`. WinRT uses different codec IDs. Create a `CodecHelper` to bridge them:
```csharp
internal static class CodecHelper
{
// Maps WPF container format GUIDs (stored in settings JSON) to WinRT encoder IDs
private static readonly Dictionary<Guid, Guid> LegacyGuidToEncoderId = new()
{
[new Guid("19e4a5aa-5662-4fc5-a0c0-1758028e1057")] = BitmapEncoder.JpegEncoderId,
[new Guid("1b7cfaf4-713f-473c-bbcd-6137425faeaf")] = BitmapEncoder.PngEncoderId,
[new Guid("0af1d87e-fcfe-4188-bdeb-a7906471cbe3")] = BitmapEncoder.BmpEncoderId,
[new Guid("163bcc30-e2e9-4f0b-961d-a3e9fdb788a3")] = BitmapEncoder.TiffEncoderId,
[new Guid("1f8a5601-7d4d-4cbd-9c82-1bc8d4eeb9a5")] = BitmapEncoder.GifEncoderId,
};
// Maps decoder IDs to corresponding encoder IDs
private static readonly Dictionary<Guid, Guid> DecoderIdToEncoderId = new()
{
[BitmapDecoder.JpegDecoderId] = BitmapEncoder.JpegEncoderId,
[BitmapDecoder.PngDecoderId] = BitmapEncoder.PngEncoderId,
// ...
};
public static Guid GetEncoderIdFromLegacyGuid(Guid legacyGuid)
=> LegacyGuidToEncoderId.GetValueOrDefault(legacyGuid, Guid.Empty);
public static Guid GetEncoderIdForDecoder(BitmapDecoder decoder)
=> DecoderIdToEncoderId.GetValueOrDefault(decoder.DecoderInformation.CodecId, Guid.Empty);
}
```
This preserves backward compatibility with existing `settings.json` files that contain WPF-era GUIDs.
## ImagingEnums Pattern (from ImageResizer)
WPF-specific enums (`PngInterlaceOption`, `TiffCompressOption`) from `System.Windows.Media.Imaging` are used in settings JSON. Create custom enums with identical integer values for backward-compatible deserialization:
```csharp
// Replace System.Windows.Media.Imaging.PngInterlaceOption
public enum PngInterlaceOption { Default = 0, On = 1, Off = 2 }
// Replace System.Windows.Media.Imaging.TiffCompressOption
public enum TiffCompressOption { Default = 0, None = 1, Ccitt3 = 2, Ccitt4 = 3, Lzw = 4, Rle = 5, Zip = 6 }
```
## Async Migration Patterns
### Method Signatures
All imaging operations become async:
| Before | After |
|--------|-------|
| `void Execute(file, settings)` | `async Task ExecuteAsync(file, settings)` |
| `IEnumerable<Error> Process()` | `async Task<IEnumerable<Error>> ProcessAsync()` |
### Parallel Processing
```csharp
// WPF (synchronous)
Parallel.ForEach(Files, new ParallelOptions { MaxDegreeOfParallelism = ... },
(file, state, i) => { Execute(file, settings); });
// WinRT (async)
await Parallel.ForEachAsync(Files, new ParallelOptions { MaxDegreeOfParallelism = ... },
async (file, ct) => { await ExecuteAsync(file, settings); });
```
### CLI Async Bridge
CLI entry points must bridge async to sync:
```csharp
return RunSilentModeAsync(cliOptions).GetAwaiter().GetResult();
```
### Task.Factory.StartNew → Task.Run
```csharp
// WPF
_ = Task.Factory.StartNew(StartExecutingWork, token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
// WinUI 3
_ = Task.Run(() => StartExecutingWorkAsync());
```
## SoftwareBitmap as Interface Type
When modules expose imaging interfaces (e.g., AI super-resolution), change parameter/return types:
```csharp
// WPF
BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath);
// WinRT
SoftwareBitmap ApplySuperResolution(SoftwareBitmap source, int scale, string filePath);
```
This eliminates manual `BitmapSource ↔ SoftwareBitmap` conversion code (unsafe `IMemoryBufferByteAccess` COM interop).
## MultiFrame Image Handling
```csharp
// WinRT multi-frame encode (e.g., multi-page TIFF, animated GIF)
for (uint i = 0; i < decoder.FrameCount; i++)
{
if (i > 0)
await encoder.GoToNextFrameAsync();
var frame = await decoder.GetFrameAsync(i);
var bitmap = await frame.GetSoftwareBitmapAsync(
frame.BitmapPixelFormat,
BitmapAlphaMode.Premultiplied,
transform,
ExifOrientationMode.IgnoreExifOrientation,
ColorManagementMode.DoNotColorManage);
encoder.SetSoftwareBitmap(bitmap);
}
await encoder.FlushAsync();
```
## int → uint for Pixel Dimensions
WinRT uses `uint` for all pixel dimensions. This affects:
- `decoder.PixelWidth` / `decoder.PixelHeight``uint`
- `BitmapTransform.ScaledWidth` / `ScaledHeight``uint`
- `SoftwareBitmap` constructor — `uint` parameters
- Test assertions: `Assert.AreEqual(96, ...)``Assert.AreEqual(96u, ...)`
## Display SoftwareBitmap in UI
```csharp
var source = new SoftwareBitmapSource();
// Must convert to Bgra8/Premultiplied for display
if (bitmap.BitmapPixelFormat != BitmapPixelFormat.Bgra8 ||
bitmap.BitmapAlphaMode != BitmapAlphaMode.Premultiplied)
{
bitmap = SoftwareBitmap.Convert(bitmap, BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied);
}
await source.SetBitmapAsync(bitmap);
myImage.Source = source;
```
## Known Limitations
| Feature | WPF | WinRT | Impact |
|---------|-----|-------|--------|
| PNG interlace | `PngBitmapEncoder.Interlace` | Not available | Always non-interlaced |
| Metadata stripping | Selective via `BitmapMetadata.Clone()` | All-or-nothing | Orientation EXIF also removed |
| Pixel formats | Many (`Pbgra32`, `Bgr24`, `Indexed8`, ...) | Primarily `Bgra8`, `Rgba8`, `Gray8/16` | Convert to `Bgra8` |
| WMP/HDP format | `WmpBitmapDecoder` | Not available | Not supported |
| Pixel differences | WPF scaler | `BitmapInterpolationMode.Fant` | Not bit-identical |

View File

@@ -1,272 +0,0 @@
# Namespace and API Mapping Reference
Complete reference for mapping WPF types to WinUI 3 equivalents, based on the ImageResizer migration.
## Root Namespace Mapping
| WPF Namespace | WinUI 3 Namespace | Notes |
|---------------|-------------------|-------|
| `System.Windows` | `Microsoft.UI.Xaml` | Root namespace |
| `System.Windows.Automation` | `Microsoft.UI.Xaml.Automation` | Accessibility / UI Automation |
| `System.Windows.Automation.Peers` | `Microsoft.UI.Xaml.Automation.Peers` | |
| `System.Windows.Controls` | `Microsoft.UI.Xaml.Controls` | Core controls |
| `System.Windows.Controls.Primitives` | `Microsoft.UI.Xaml.Controls.Primitives` | Low-level primitives |
| `System.Windows.Data` | `Microsoft.UI.Xaml.Data` | Binding, IValueConverter |
| `System.Windows.Documents` | `Microsoft.UI.Xaml.Documents` | Limited — RichTextBlock + Paragraph only |
| `System.Windows.Input` | `Microsoft.UI.Xaml.Input` | Pointer, keyboard, focus |
| `System.Windows.Markup` | `Microsoft.UI.Xaml.Markup` | XAML parsing, markup extensions |
| `System.Windows.Media` | `Microsoft.UI.Xaml.Media` | Brushes, transforms |
| `System.Windows.Media.Animation` | `Microsoft.UI.Xaml.Media.Animation` | Storyboard, animations |
| `System.Windows.Media.Imaging` | `Microsoft.UI.Xaml.Media.Imaging` | UI display only — use `Windows.Graphics.Imaging` for processing |
| `System.Windows.Media.Media3D` | **No equivalent** | Use Win2D or Composition APIs |
| `System.Windows.Navigation` | `Microsoft.UI.Xaml.Navigation` | For Frame navigation events; no `NavigationService` |
| `System.Windows.Shapes` | `Microsoft.UI.Xaml.Shapes` | Rectangle, Ellipse, Path |
| `System.Windows.Threading` | `Microsoft.UI.Dispatching` | Dispatcher → DispatcherQueue |
| `System.Windows.Interop` | `WinRT.Interop` | HWND interop |
| `System.Windows.Interop` | `Microsoft.UI.Xaml.Hosting` | XAML Islands |
## Core Type Mapping
| WPF Type | WinUI 3 Type |
|----------|-------------|
| `System.Windows.Application` | `Microsoft.UI.Xaml.Application` |
| `System.Windows.Window` | `Microsoft.UI.Xaml.Window` (NOT a DependencyObject) |
| `System.Windows.DependencyObject` | `Microsoft.UI.Xaml.DependencyObject` |
| `System.Windows.DependencyProperty` | `Microsoft.UI.Xaml.DependencyProperty` |
| `System.Windows.FrameworkElement` | `Microsoft.UI.Xaml.FrameworkElement` |
| `System.Windows.UIElement` | `Microsoft.UI.Xaml.UIElement` |
| `System.Windows.Visibility` | `Microsoft.UI.Xaml.Visibility` |
| `System.Windows.Thickness` | `Microsoft.UI.Xaml.Thickness` |
| `System.Windows.CornerRadius` | `Microsoft.UI.Xaml.CornerRadius` |
| `System.Windows.Media.Color` | `Windows.UI.Color` (note: `Windows.UI`, not `Microsoft.UI`) |
| `System.Windows.Media.Colors` | `Microsoft.UI.Colors` |
## Controls Mapping
### Direct Mapping (namespace-only change)
These controls exist in both frameworks with the same name — change `System.Windows.Controls` to `Microsoft.UI.Xaml.Controls`:
`Button`, `TextBox`, `TextBlock`, `ComboBox`, `CheckBox`, `ListView`, `Image`, `StackPanel`, `Grid`, `Border`, `ScrollViewer`, `ContentControl`, `UserControl`, `Page`, `Frame`, `Slider`, `ProgressBar`, `ToolTip`, `RadioButton`, `ToggleButton`
### Controls With Different Names or Behavior
| WPF | WinUI 3 | Notes |
|-----|---------|-------|
| `MessageBox` | `ContentDialog` | Must set `XamlRoot` before `ShowAsync()` |
| `ContextMenu` | `MenuFlyout` | Different API surface |
| `TabControl` | `TabView` | Different API |
| `Menu` | `MenuBar` | Different API |
| `StatusBar` | Custom `StackPanel` layout | No built-in equivalent |
| `ListBox` | `ListView` (or `ItemsView`) | **Deprecated in WinUI 3 — do not use.** Prefer `ListView`, or `ItemsView` (WinUI 1.5+) for modern collection scenarios |
| `AccessText` | Not available | Use `AccessKey` property on target control |
### WPF-UI (Lepo) to Native WinUI 3
ImageResizer used the `WPF-UI` library (Lepo) for Fluent styling. These must be replaced with native WinUI 3 equivalents:
| WPF-UI (Lepo) | WinUI 3 Native | Notes |
|----------------|---------------|-------|
| `<ui:FluentWindow>` | `<Window>` | Native window + `ExtendsContentIntoTitleBar` |
| `<ui:Button>` | `<Button>` | Native button |
| `<ui:NumberBox>` | `<NumberBox>` | Built into WinUI 3 |
| `<ui:ProgressRing>` | `<ProgressRing>` | Built into WinUI 3 |
| `<ui:SymbolIcon>` | `<SymbolIcon>` or `<FontIcon>` | Built into WinUI 3 |
| `<ui:InfoBar>` | `<InfoBar>` | Built into WinUI 3 |
| `<ui:TitleBar>` | Custom title bar via `SetTitleBar()` | Use `ExtendsContentIntoTitleBar` |
| `<ui:ThemesDictionary>` | `<XamlControlsResources>` | In merged dictionaries |
| `<ui:ControlsDictionary>` | Remove | Not needed — WinUI 3 has its own control styles |
| `BasedOn="{StaticResource {x:Type ui:Button}}"` | `BasedOn="{StaticResource DefaultButtonStyle}"` | Named style keys |
## Input Event Mapping
| WPF Event | WinUI 3 Event | Notes |
|-----------|--------------|-------|
| `MouseLeftButtonDown` | `PointerPressed` | Check `IsLeftButtonPressed` on args |
| `MouseLeftButtonUp` | `PointerReleased` | Check pointer properties |
| `MouseRightButtonDown` | `RightTapped` | Or `PointerPressed` with right button check |
| `MouseMove` | `PointerMoved` | Uses `PointerRoutedEventArgs` |
| `MouseWheel` | `PointerWheelChanged` | Different event args |
| `MouseEnter` | `PointerEntered` | |
| `MouseLeave` | `PointerExited` | |
| `MouseDoubleClick` | `DoubleTapped` | Different event args |
| `KeyDown` | `KeyDown` | Same name, args type: `KeyRoutedEventArgs` |
| `PreviewKeyDown` | No direct equivalent | Use `KeyDown` with handled pattern |
## IValueConverter Signature Change
| WPF | WinUI 3 |
|-----|---------|
| `Convert(object value, Type targetType, object parameter, CultureInfo culture)` | `Convert(object value, Type targetType, object parameter, string language)` |
| `ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)` | `ConvertBack(object value, Type targetType, object parameter, string language)` |
Last parameter changes from `CultureInfo` to `string` (BCP-47 language tag). All converter classes must be updated.
## Types That Moved to Different Hierarchies
| WPF | WinUI 3 | Notes |
|-----|---------|-------|
| `System.Windows.Threading.Dispatcher` | `Microsoft.UI.Dispatching.DispatcherQueue` | Completely different API |
| `System.Windows.Threading.DispatcherPriority` | `Microsoft.UI.Dispatching.DispatcherQueuePriority` | Only 3 levels: High/Normal/Low |
| `System.Windows.Interop.HwndSource` | `WinRT.Interop.WindowNative` | For HWND interop |
| `System.Windows.Interop.WindowInteropHelper` | `WinRT.Interop.WindowNative.GetWindowHandle()` | |
| `System.Windows.SystemColors` | Resource keys via `ThemeResource` | No direct static class |
| `System.Windows.SystemParameters` | Win32 API or `DisplayInformation` | No direct equivalent |
| `System.Windows.Clipboard` | `Windows.ApplicationModel.DataTransfer.Clipboard` | Different API surface |
| `System.Windows.Input.RoutedUICommand` | `Microsoft.UI.Xaml.Input.StandardUICommand` / `XamlUICommand` | Or use `ICommand` / `[RelayCommand]` |
| `System.Windows.Input.CommandBinding` | **Remove** | Bind `ICommand` directly in XAML |
## Controls That Need Translation (No 1:1 Mapping)
These controls exist in WPF but require a different control, third-party library, or Community Toolkit package in WinUI 3:
| WPF Control | WinUI 3 Replacement | Package / Notes |
|-------------|---------------------|-----------------|
| `DataGrid` | [`WinUI.TableView`](https://github.com/w-ahmad/WinUI.TableView) | Community library; the Toolkit `DataGrid` is no longer maintained. PowerToys legacy modules may still pin v7 `CommunityToolkit.WinUI.UI.Controls.DataGrid` 7.1.2 — prefer `WinUI.TableView` for new work. |
| `Ribbon` | `CommandBar` / `NavigationView`, or [Toolkit Labs Ribbon](https://github.com/CommunityToolkit/Labs-Windows/tree/main/components/Ribbon) | No first-party Ribbon in WinUI; the Labs component is experimental and partial |
| `Menu` / `MenuItem` | `MenuBar` / `MenuBarItem` / `MenuFlyoutItem` | `MenuBar` for classic menu, `MenuFlyout` for context |
| `ContextMenu` | `MenuFlyout` | Assign to `ContextFlyout` property |
| `ToolBar` / `ToolBarTray` | `CommandBar` + `AppBarButton` | |
| `StatusBar` | Custom `Grid`/`StackPanel` or `InfoBar` | No StatusBar control |
| `TabControl` | `TabView` or `NavigationView` (top mode) | `TabView` for closeable tabs |
| `DocumentViewer` | `WebView2` | `Microsoft.Web.WebView2` — render PDFs/XPS |
| `FlowDocument` | `RichTextBlock` | Partial replacement only |
| `RichTextBox` | `RichEditBox` | Rich text editing |
| `GroupBox` | `Expander` (built-in) or `HeaderedContentControl` (Toolkit) | See [Layout & Header Controls from CommunityToolkit.WinUI](#layout--header-controls-from-communitytoolkitwinui) below |
| `Label` | `TextBlock` | WPF `Label` is a `ContentControl`; use `TextBlock` + `AccessKey` |
| `TreeView` | `TreeView` (native) | Available natively, but data binding model differs significantly |
| `MediaElement` | `MediaPlayerElement` | Different API |
## Layout & Header Controls from CommunityToolkit.WinUI
These WPF layout/header controls have no built-in WinUI 3 equivalent — install the corresponding CommunityToolkit package. **The NuGet package id and the XAML namespace differ intentionally**: package names end in `.Primitives` / `.HeaderedControls`, but both packages register their controls in the shorter `CommunityToolkit.WinUI.Controls` XAML namespace (confirmed in the [official Microsoft Q&A](https://learn.microsoft.com/en-us/answers/questions/5746230/why-does-communitytoolkit-uwp-controls-primitive-u): *"all controls live under `CommunityToolkit.WinUI.Controls` … This is intentional"*).
| WPF Control | WinUI 3 Replacement | NuGet Package | XAML Namespace |
|-------------|---------------------|---------------|----------------|
| `WrapPanel` | `WrapPanel` | `CommunityToolkit.WinUI.Controls.Primitives` | `using:CommunityToolkit.WinUI.Controls` |
| `UniformGrid` | `UniformGrid` | `CommunityToolkit.WinUI.Controls.Primitives` | `using:CommunityToolkit.WinUI.Controls` |
| `DockPanel` | `DockPanel` | `CommunityToolkit.WinUI.Controls.Primitives` | `using:CommunityToolkit.WinUI.Controls` |
| `GroupBox` (alt.) | `HeaderedContentControl` | `CommunityToolkit.WinUI.Controls.HeaderedControls` | `using:CommunityToolkit.WinUI.Controls` |
Other primitives in `Primitives`: `ConstrainedBox`, `SwitchPresenter`, `WrapLayout`, `StaggeredPanel`. Other Headered controls in `HeaderedControls`: `HeaderedItemsControl`, `HeaderedTreeView`.
## NuGet Package Migration
| WPF | WinUI 3 | Notes |
|-----|---------|-------|
| Built into .NET (no NuGet needed) | `Microsoft.WindowsAppSDK` | Required |
| `PresentationCore` / `PresentationFramework` | `Microsoft.WinUI` (transitive) | |
| `Microsoft.Xaml.Behaviors.Wpf` | `Microsoft.Xaml.Behaviors.WinUI.Managed` | |
| `WPF-UI` (Lepo) | **Remove** — use native WinUI 3 controls | |
| `CommunityToolkit.Mvvm` | `CommunityToolkit.Mvvm` (same) | |
| `Microsoft.Toolkit.Wpf.*` | `CommunityToolkit.WinUI.*` | |
| (none) | `Microsoft.Windows.SDK.BuildTools` | Required |
| (none) | `WinUIEx` | Optional, window helpers |
| (none) | `CommunityToolkit.WinUI.Converters` | Optional |
| (none) | `CommunityToolkit.WinUI.Extensions` | Optional |
| (none) | `CommunityToolkit.WinUI.Controls.Primitives` | Optional — `WrapPanel`, `UniformGrid`, `DockPanel`, `ConstrainedBox`, `SwitchPresenter` |
| (none) | `CommunityToolkit.WinUI.Controls.HeaderedControls` | Optional — `HeaderedContentControl`, `HeaderedItemsControl`, `HeaderedTreeView` |
| (none) | `CommunityToolkit.WinUI.Controls.SettingsControls` | Optional — `SettingsCard`, `SettingsExpander` |
| (none) | `CommunityToolkit.WinUI.Controls.Sizers` | Optional — `GridSplitter`, `PropertySizer` |
| (none) | `CommunityToolkit.WinUI.UI.Controls.DataGrid` | Legacy v7 — only if migrating existing `DataGrid` code; prefer [`WinUI.TableView`](https://github.com/w-ahmad/WinUI.TableView) for new work |
| (none) | `Microsoft.Web.WebView2` | If using WebView |
## Project File Changes
### WPF .csproj
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ApplicationManifest>ImageResizerUI.dev.manifest</ApplicationManifest>
<ApplicationIcon>Resources\ImageResizer.ico</ApplicationIcon>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
</Project>
```
### WinUI 3 .csproj
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<UseWinUI>true</UseWinUI>
<SelfContained>true</SelfContained>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<WindowsPackageType>None</WindowsPackageType>
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\ImageResizer\ImageResizer.ico</ApplicationIcon>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants>
<ProjectPriFileName>PowerToys.ModuleName.pri</ProjectPriFileName>
</PropertyGroup>
</Project>
```
Key changes:
- `UseWPF``UseWinUI`
- TFM: `net8.0-windows``net8.0-windows10.0.19041.0`
- **CRITICAL: Add `WindowsPackageType=None`** — marks the app as unpackaged (no MSIX). Without this, the build produces an MSIX-style package that won't run as a standalone PowerToys module.
- **CRITICAL: Add `WindowsAppSDKSelfContained=true`** — bundles the Windows App SDK runtime DLLs (e.g. `Microsoft.UI.Xaml.dll`) into the output directory. Without this, the app throws `COMException: ClassFactory cannot supply requested class` at startup because the WinUI 3 COM classes cannot be found.
- Add `SelfContained=true` (usually via `Common.SelfContained.props`)
- Add `DISABLE_XAML_GENERATED_MAIN` if using custom `Program.cs` entry point
- Set `ProjectPriFileName` to match your module's assembly name
- Move icon from `Resources/` to `Assets/<Module>/`
### XAML ApplicationDefinition Setup
WinUI 3 requires explicit `ApplicationDefinition` declaration:
```xml
<ItemGroup>
<Page Remove="ImageResizerXAML\App.xaml" />
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="ImageResizerXAML\App.xaml" />
</ItemGroup>
```
### CsWinRT Interop (for GPO and native references)
If the module references native C++ projects (like `GPOWrapper`):
```xml
<PropertyGroup>
<CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes>
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
</PropertyGroup>
```
Change `GPOWrapperProjection.csproj` reference to direct `GPOWrapper.vcxproj` reference.
### InternalsVisibleTo Migration
Move from code file to `.csproj`:
```csharp
// DELETE: Properties/InternalsVisibleTo.cs
// [assembly: InternalsVisibleTo("ImageResizer.Test")]
```
```xml
<!-- ADD to .csproj: -->
<ItemGroup>
<InternalsVisibleTo Include="ImageResizer.Test" />
</ItemGroup>
```
### Items to Remove from .csproj
```xml
<!-- DELETE: WPF resource embedding -->
<EmbeddedResource Update="Properties\Resources.resx">...</EmbeddedResource>
<Resource Include="Resources\ImageResizer.ico" />
<Compile Update="Properties\Resources.Designer.cs">...</Compile>
<FrameworkReference Include="Microsoft.WindowsDesktop.App.WPF" /> <!-- from CLI project -->
```

View File

@@ -1,516 +0,0 @@
# PowerToys-Specific Migration Patterns
Patterns and conventions specific to the PowerToys codebase, based on the ImageResizer migration.
## Project Structure
### Before (WPF Module)
```
src/modules/<module>/
├── <Module>UI/
│ ├── <Module>UI.csproj # OutputType=WinExe, UseWPF=true
│ ├── App.xaml / App.xaml.cs
│ ├── MainWindow.xaml / .cs
│ ├── Views/
│ ├── ViewModels/
│ ├── Helpers/
│ │ ├── Observable.cs # Custom INotifyPropertyChanged
│ │ └── RelayCommand.cs # Custom ICommand
│ ├── Properties/
│ │ ├── Resources.resx # WPF resource strings
│ │ ├── Resources.Designer.cs
│ │ └── InternalsVisibleTo.cs
│ └── Telemetry/
├── <Module>CLI/
│ └── <Module>CLI.csproj # OutputType=Exe
└── tests/
```
### After (WinUI 3 Module)
```
src/modules/<module>/
├── <Module>UI/
│ ├── <Module>UI.csproj # OutputType=WinExe, UseWinUI=true
│ ├── Program.cs # Custom entry point (DISABLE_XAML_GENERATED_MAIN)
│ ├── app.manifest # Single manifest file
│ ├── ImageResizerXAML/
│ │ ├── App.xaml / App.xaml.cs # WinUI 3 App class
│ │ ├── MainWindow.xaml / .cs
│ │ └── Views/
│ ├── Converters/ # WinUI 3 IValueConverter (string language)
│ ├── ViewModels/
│ ├── Helpers/
│ │ └── ResourceLoaderInstance.cs # Static ResourceLoader accessor
│ ├── Utilities/
│ │ └── CodecHelper.cs # WPF→WinRT codec ID mapping (if imaging)
│ ├── Models/
│ │ └── ImagingEnums.cs # Custom enums replacing WPF imaging enums
│ ├── Strings/
│ │ └── en-us/
│ │ └── Resources.resw # WinUI 3 resource strings
│ └── Assets/
│ └── <Module>/
│ └── <Module>.ico # Moved from Resources/
├── <Module>Common/ # NEW: shared library for CLI
│ └── <Module>Common.csproj # OutputType=Library
├── <Module>CLI/
│ └── <Module>CLI.csproj # References Common, NOT UI
└── tests/
```
### Critical: CLI Dependency Pattern
**Do NOT** create `ProjectReference` from Exe to WinExe. This causes phantom build artifacts (`.exe`, `.deps.json`, `.runtimeconfig.json`) in the root output directory.
```
WRONG: ImageResizerCLI (Exe) → ImageResizerUI (WinExe) ← phantom artifacts
CORRECT: ImageResizerCLI (Exe) → ImageResizerCommon (Library)
ImageResizerUI (WinExe) → ImageResizerCommon (Library)
```
Follow the `FancyZonesCLI``FancyZonesEditorCommon` pattern.
### Files to Delete
| File | Reason |
|------|--------|
| `Properties/Resources.resx` | Replaced by `Strings/en-us/Resources.resw` |
| `Properties/Resources.Designer.cs` | Auto-generated; no longer needed |
| `Properties/InternalsVisibleTo.cs` | Moved to `.csproj` `<InternalsVisibleTo>` |
| `Helpers/Observable.cs` | Replaced by `CommunityToolkit.Mvvm.ObservableObject` |
| `Helpers/RelayCommand.cs` | Replaced by `CommunityToolkit.Mvvm.Input` |
| `Resources/*.ico` / `Resources/*.png` | Moved to `Assets/<Module>/` |
| WPF `.dev.manifest` / `.prod.manifest` | Replaced by single `app.manifest` |
| WPF-specific converters | Replaced by WinUI 3 converters with `string language` |
---
## MVVM Migration: Custom → CommunityToolkit.Mvvm Source Generators
### Observable Base Class → ObservableObject + [ObservableProperty]
**Before (custom Observable):**
```csharp
public class ResizeSize : Observable
{
private int _id;
public int Id { get => _id; set => Set(ref _id, value); }
private ResizeFit _fit;
public ResizeFit Fit
{
get => _fit;
set
{
Set(ref _fit, value);
UpdateShowHeight();
}
}
private bool _showHeight = true;
public bool ShowHeight { get => _showHeight; set => Set(ref _showHeight, value); }
private void UpdateShowHeight() { ShowHeight = Fit == ResizeFit.Stretch || Unit != ResizeUnit.Percent; }
}
```
**After (CommunityToolkit.Mvvm source generators):**
```csharp
public partial class ResizeSize : ObservableObject // MUST be partial
{
[ObservableProperty]
[JsonPropertyName("Id")]
private int _id;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowHeight))] // Replaces manual UpdateShowHeight()
private ResizeFit _fit;
// Computed property — no backing field, no manual update method
public bool ShowHeight => Fit == ResizeFit.Stretch || Unit != ResizeUnit.Percent;
}
```
Key changes:
- Class must be `partial` for source generators
- `Observable``ObservableObject` (from CommunityToolkit.Mvvm)
- Manual `Set(ref _field, value)``[ObservableProperty]` attribute
- `PropertyChanged` dependencies → `[NotifyPropertyChangedFor(nameof(...))]`
- Computed properties with manual `UpdateXxx()` → direct expression body
### Custom Name Setter with Transform
For properties that transform the value before storing:
```csharp
// Cannot use [ObservableProperty] because of value transformation
private string _name;
public string Name
{
get => _name;
set => SetProperty(ref _name, ReplaceTokens(value)); // SetProperty from ObservableObject
}
```
### RelayCommand → [RelayCommand] Source Generator
```csharp
// DELETE: Helpers/RelayCommand.cs (custom ICommand)
// Before
public ICommand ResizeCommand { get; } = new RelayCommand(Execute);
// After
[RelayCommand]
private void Resize() { /* ... */ }
// Source generator creates ResizeCommand property automatically
```
---
## Resource String Migration (.resx → .resw)
### ResourceLoaderInstance Helper
```csharp
internal static class ResourceLoaderInstance
{
internal static ResourceLoader ResourceLoader { get; private set; }
static ResourceLoaderInstance()
{
ResourceLoader = new ResourceLoader("PowerToys.ImageResizer.pri");
}
}
```
**Note**: Use the single-argument `ResourceLoader` constructor. The two-argument version (`ResourceLoader("file.pri", "path/Resources")`) may fail if the resource map path doesn't match the actual PRI structure.
### Usage
```csharp
// WPF
using ImageResizer.Properties;
string text = Resources.MyStringKey;
// WinUI 3
string text = ResourceLoaderInstance.ResourceLoader.GetString("MyStringKey");
```
### Lazy Initialization for Resource-Dependent Statics
`ResourceLoader` is not available at class-load time in all contexts (CLI mode, test harness). Use lazy initialization:
**Before (crashes at class load):**
```csharp
private static readonly CompositeFormat _format =
CompositeFormat.Parse(Resources.Error_Format);
private static readonly Dictionary<string, string> _tokens = new()
{
["$small$"] = Resources.Small,
["$medium$"] = Resources.Medium,
};
```
**After (lazy, safe):**
```csharp
private static CompositeFormat _format;
private static CompositeFormat Format => _format ??=
CompositeFormat.Parse(ResourceLoaderInstance.ResourceLoader.GetString("Error_Format"));
private static readonly Lazy<Dictionary<string, string>> _tokens = new(() =>
new Dictionary<string, string>
{
["$small$"] = ResourceLoaderInstance.ResourceLoader.GetString("Small"),
["$medium$"] = ResourceLoaderInstance.ResourceLoader.GetString("Medium"),
});
// Usage: _tokens.Value.TryGetValue(...)
```
### XAML: x:Static → x:Uid
```xml
<!-- WPF -->
<Button Content="{x:Static p:Resources.Cancel}" />
<!-- WinUI 3 -->
<Button x:Uid="Cancel" />
```
In `.resw`, use property-suffixed keys: `Cancel.Content`, `Header.Text`, etc.
---
## CLI Options Migration
`System.CommandLine.Option<T>` constructor signature changed:
```csharp
// WPF era — string[] aliases
public DestinationOption()
: base(_aliases, Properties.Resources.CLI_Option_Destination)
// WinUI 3 — single string name
public DestinationOption()
: base(_aliases[0], ResourceLoaderInstance.ResourceLoader.GetString("CLI_Option_Destination"))
```
---
## Installer Updates
### WiX Changes
#### 1. Remove Satellite Assembly References
Remove from `installer/PowerToysSetupVNext/Resources.wxs`:
- `<Component>` entries for `<Module>.resources.dll`
- `<RemoveFolder>` entries for locale directories
- Module from `WinUI3AppsInstallFolder` `ParentDirectory` loop
#### 2. Update File Component Generation
Run `generateAllFileComponents.ps1` after migration. For Exe→WinExe dependency issues, add cleanup logic:
```powershell
# Strip phantom ImageResizer files from BaseApplications.wxs
$content = $content -replace 'PowerToys\.ImageResizer\.exe', ''
$content = $content -replace 'PowerToys\.ImageResizer\.deps\.json', ''
$content = $content -replace 'PowerToys\.ImageResizer\.runtimeconfig\.json', ''
```
#### 3. Output Directory
WinUI 3 modules output to `WinUI3Apps/`:
```xml
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutputPath>
```
### ESRP Signing
Update `.pipelines/ESRPSigning_core.json` — all module binaries must use `WinUI3Apps\\` paths:
```json
{
"FileList": [
"WinUI3Apps\\PowerToys.ImageResizer.exe",
"WinUI3Apps\\PowerToys.ImageResizerExt.dll",
"WinUI3Apps\\PowerToys.ImageResizerContextMenu.dll"
]
}
```
---
## Build Pipeline Fixes
### $(SolutionDir) → $(MSBuildThisFileDirectory)
`$(SolutionDir)` is empty when building individual projects outside the solution. Replace with relative paths from the project file:
```xml
<!-- Before (breaks on standalone project build) -->
<Exec Command="powershell $(SolutionDir)tools\build\convert-resx-to-rc.ps1" />
<!-- After (works always) -->
<Exec Command="powershell $(MSBuildThisFileDirectory)..\..\..\..\tools\build\convert-resx-to-rc.ps1" />
```
### MSIX Packaging: PreBuild → PostBuild
MSIX packaging must happen AFTER the build (artifacts not ready at PreBuild):
```xml
<!-- Before -->
<PreBuildEvent>MakeAppx.exe pack /d . /p "$(OutDir)Package.msix" /o</PreBuildEvent>
<!-- After -->
<PostBuildEvent>
if exist "$(OutDir)Package.msix" del "$(OutDir)Package.msix"
MakeAppx.exe pack /d "$(MSBuildThisFileDirectory)." /p "$(OutDir)Package.msix" /o
</PostBuildEvent>
```
### RC File Icon Path Escaping
Windows Resource Compiler requires double-backslash paths:
```c
// Before (breaks)
IDI_ICON1 ICON "..\\ui\Assets\ImageResizer\ImageResizer.ico"
// After
IDI_ICON1 ICON "..\\ui\\Assets\\ImageResizer\\ImageResizer.ico"
```
### BOM/Encoding Normalization
Migration may strip UTF-8 BOM from C# files (`// Copyright``// Copyright`). This is cosmetic and safe, but be aware it will show as changes in diff.
---
## Test Adaptation
### Tests Requiring WPF Runtime
If tests still need WPF types (e.g., comparing old vs new output), temporarily add:
```xml
<UseWPF>true</UseWPF>
```
Remove this after fully migrating all test code to WinRT APIs.
### Tests Using ResourceLoader
Unit tests cannot easily initialize WinUI 3 `ResourceLoader`. Options:
- Hardcode expected strings in tests: `"Value must be between '{0}' and '{1}'."`
- Delete tests that only verify resource string lookup
- Avoid creating `App` instances in test harness (WinUI App cannot be instantiated in tests)
### Async Test Methods
All imaging tests become async:
```csharp
// Before
[TestMethod]
public void ResizesImage() { ... }
// After
[TestMethod]
public async Task ResizesImageAsync() { ... }
```
### uint Assertions
```csharp
// Before
Assert.AreEqual(96, image.Frames[0].PixelWidth);
// After
Assert.AreEqual(96u, decoder.PixelWidth);
```
### Pixel Data Access in Tests
```csharp
// Before (WPF)
public static Color GetFirstPixel(this BitmapSource source)
{
var pixel = new byte[4];
new FormatConvertedBitmap(
new CroppedBitmap(source, new Int32Rect(0, 0, 1, 1)),
PixelFormats.Bgra32, null, 0).CopyPixels(pixel, 4, 0);
return Color.FromArgb(pixel[3], pixel[2], pixel[1], pixel[0]);
}
// After (WinRT)
public static async Task<(byte R, byte G, byte B, byte A)> GetFirstPixelAsync(
this BitmapDecoder decoder)
{
using var bitmap = await decoder.GetSoftwareBitmapAsync(
BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied);
var buffer = new Windows.Storage.Streams.Buffer(
(uint)(bitmap.PixelWidth * bitmap.PixelHeight * 4));
bitmap.CopyToBuffer(buffer);
using var reader = DataReader.FromBuffer(buffer);
byte b = reader.ReadByte(), g = reader.ReadByte(),
r = reader.ReadByte(), a = reader.ReadByte();
return (r, g, b, a);
}
```
### Metadata Assertions
```csharp
// Before
Assert.AreEqual("Test", ((BitmapMetadata)image.Frames[0].Metadata).Comment);
// After
var props = await decoder.BitmapProperties.GetPropertiesAsync(
new[] { "System.Photo.DateTaken" });
Assert.IsTrue(props.ContainsKey("System.Photo.DateTaken"),
"Metadata should be preserved during transcode");
```
### AllowUnsafeBlocks for SoftwareBitmap Tests
If tests access pixel data via `IMemoryBufferByteAccess`, add:
```xml
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
```
---
## Settings JSON Backward Compatibility
- Settings are stored in `%LOCALAPPDATA%\Microsoft\PowerToys\<ModuleName>\`
- Schema must remain backward-compatible across upgrades
- Add new fields with defaults; never remove or rename existing fields
- Create custom enums matching WPF enum integer values for deserialization (e.g., `ImagingEnums.cs`)
- See: `src/settings-ui/Settings.UI.Library/`
## IPC Contract
If the module communicates with the runner or settings UI:
1. Update BOTH sides of the IPC contract
2. Test settings changes are received by the module
3. Test module state changes are reflected in settings UI
4. Reference: `doc/devdocs/core/settings/runner-ipc.md`
---
## Checklist for PowerToys Module Migration
### Project & Dependencies
- [ ] Update `.csproj`: `UseWPF``UseWinUI`, TFM → `net8.0-windows10.0.19041.0`
- [ ] Add `WindowsPackageType=None`, `SelfContained=true`, `WindowsAppSDKSelfContained=true`
- [ ] Add `DISABLE_XAML_GENERATED_MAIN` if using custom `Program.cs`
- [ ] Replace NuGet packages (WPF-UI → remove, add WindowsAppSDK, etc.)
- [ ] Update project references (GPOWrapperProjection → GPOWrapper + CsWinRT)
- [ ] Move `InternalsVisibleTo` from code to `.csproj`
- [ ] Extract CLI shared logic to Library project (avoid Exe→WinExe dependency)
### MVVM & Resources
- [ ] Replace custom `Observable`/`RelayCommand` with CommunityToolkit.Mvvm source generators
- [ ] Migrate `.resx``.resw` (`Properties/Resources.resx``Strings/en-us/Resources.resw`)
- [ ] Create `ResourceLoaderInstance` helper
- [ ] Wrap resource-dependent statics in `Lazy<T>` or null-coalescing properties
- [ ] Delete `Properties/Resources.Designer.cs`, `Observable.cs`, `RelayCommand.cs`
### XAML
- [ ] Replace `clr-namespace:``using:` in all xmlns declarations
- [ ] Remove WPF-UI (Lepo) xmlns and controls — use native WinUI 3
- [ ] Replace `{x:Static p:Resources.Key}``x:Uid` with `.resw` keys
- [ ] Replace `{DynamicResource}``{ThemeResource}`
- [ ] Replace `DataType="{x:Type ...}"``x:DataType="..."`
- [ ] Replace `<Style.Triggers>``VisualStateManager`
- [ ] Add `<XamlControlsResources/>` to `App.xaml` merged dictionaries
- [ ] Move `Window.Resources` to root container's `Resources`
- [ ] Run XamlStyler: `.\.pipelines\applyXamlStyling.ps1 -Main`
### Code-Behind & APIs
- [ ] Replace all `System.Windows.*` namespaces with `Microsoft.UI.Xaml.*`
- [ ] Replace `Dispatcher` with `DispatcherQueue`
- [ ] Store `DispatcherQueue` reference explicitly (no `Application.Current.Dispatcher`)
- [ ] Implement `SizeToContent()` via AppWindow if needed
- [ ] Update `ContentDialog` calls to set `XamlRoot`
- [ ] Update `FilePicker` calls with HWND initialization
- [ ] Migrate imaging code to `Windows.Graphics.Imaging` (async, `SoftwareBitmap`)
- [ ] Create `CodecHelper` for legacy GUID → WinRT codec ID mapping (if imaging)
- [ ] Create custom imaging enums for JSON backward compatibility (if imaging)
- [ ] Update all `IValueConverter` signatures (`CultureInfo``string`)
### Build & Installer
- [ ] Update WiX installer: remove satellite assembly refs from `Resources.wxs`
- [ ] Run `generateAllFileComponents.ps1`; handle phantom artifacts
- [ ] Update ESRP signing paths to `WinUI3Apps\\`
- [ ] Fix `$(SolutionDir)``$(MSBuildThisFileDirectory)` in build events
- [ ] Move MSIX packaging from PreBuild to PostBuild
- [ ] Fix RC file path escaping (double-backslash)
- [ ] Verify output dir is `WinUI3Apps/`
### Testing & Validation
- [ ] Update test project: async methods, `uint` assertions
- [ ] Handle ResourceLoader unavailability in tests (hardcode strings or skip)
- [ ] Build clean: `cd` to project folder, `tools/build/build.cmd`, exit code 0
- [ ] Run tests for affected module
- [ ] Verify settings JSON backward compatibility
- [ ] Test IPC contracts (runner ↔ settings UI)

View File

@@ -1,314 +0,0 @@
# Threading and Window Management Migration
Based on patterns from the ImageResizer migration.
## Dispatcher → DispatcherQueue
### API Mapping
| WPF | WinUI 3 |
|-----|---------|
| `Dispatcher.Invoke(Action)` | `DispatcherQueue.TryEnqueue(Action)` |
| `Dispatcher.BeginInvoke(Action)` | `DispatcherQueue.TryEnqueue(Action)` |
| `Dispatcher.Invoke(DispatcherPriority, Action)` | `DispatcherQueue.TryEnqueue(DispatcherQueuePriority, Action)` |
| `Dispatcher.CheckAccess()` | `DispatcherQueue.HasThreadAccess` |
| `Dispatcher.VerifyAccess()` | Check `DispatcherQueue.HasThreadAccess` (no exception-throwing method) |
### Priority Mapping
WinUI 3 has only 3 levels: `High`, `Normal`, `Low`.
| WPF `DispatcherPriority` | WinUI 3 `DispatcherQueuePriority` |
|-------------------------|----------------------------------|
| `Send` | `High` |
| `Normal` / `Input` / `Loaded` / `Render` / `DataBind` | `Normal` |
| `Background` / `ContextIdle` / `ApplicationIdle` / `SystemIdle` | `Low` |
### Pattern: Global DispatcherQueue Access (from ImageResizer)
WPF provided `Application.Current.Dispatcher` globally. WinUI 3 requires explicit storage:
```csharp
// Store DispatcherQueue at app startup
private static DispatcherQueue _uiDispatcherQueue;
public static void InitializeDispatcher()
{
_uiDispatcherQueue = DispatcherQueue.GetForCurrentThread();
}
```
Usage with thread-check pattern (from `Settings.Reload()`):
```csharp
var currentDispatcher = DispatcherQueue.GetForCurrentThread();
if (currentDispatcher != null)
{
// Already on UI thread
ReloadCore(jsonSettings);
}
else if (_uiDispatcherQueue != null)
{
// Dispatch to UI thread
_uiDispatcherQueue.TryEnqueue(() => ReloadCore(jsonSettings));
}
else
{
// Fallback (e.g., CLI mode, no UI)
ReloadCore(jsonSettings);
}
```
### Pattern: DispatcherQueue in ViewModels (from ProgressViewModel)
```csharp
public class ProgressViewModel
{
private readonly DispatcherQueue _dispatcherQueue;
public ProgressViewModel()
{
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
}
private void OnProgressChanged(double progress)
{
_dispatcherQueue.TryEnqueue(() =>
{
Progress = progress;
// other UI updates...
});
}
}
```
### Pattern: Async Dispatch (await)
```csharp
// WPF
await this.Dispatcher.InvokeAsync(() => { /* UI work */ });
// WinUI 3 (using TaskCompletionSource)
var tcs = new TaskCompletionSource();
this.DispatcherQueue.TryEnqueue(() =>
{
try { /* UI work */ tcs.SetResult(); }
catch (Exception ex) { tcs.SetException(ex); }
});
await tcs.Task;
```
### C++/WinRT Threading
| Old API | New API |
|---------|---------|
| `winrt::resume_foreground(CoreDispatcher)` | `wil::resume_foreground(DispatcherQueue)` |
| `CoreDispatcher.RunAsync()` | `DispatcherQueue.TryEnqueue()` |
Add `Microsoft.Windows.ImplementationLibrary` NuGet for `wil::resume_foreground`.
---
## Window Management
### WPF Window vs WinUI 3 Window
| Feature | WPF `Window` | WinUI 3 `Window` |
|---------|-------------|------------------|
| Base class | `ContentControl``DependencyObject` | **NOT** a control, NOT a `DependencyObject` |
| `Resources` property | Yes | No — use root container's `Resources` |
| `DataContext` property | Yes | No — use root `Page`/`UserControl` |
| `VisualStateManager` | Yes | No — use inside child controls |
| `Load`/`Unload` events | Yes | No |
| `SizeToContent` | Yes (`Height`/`Width`/`WidthAndHeight`) | No — must implement manually |
| `WindowState` (min/max/normal) | Yes | No — use `AppWindow.Presenter` |
| `WindowStyle` | Yes | No — use `AppWindow` title bar APIs |
| `ResizeMode` | Yes | No — use `AppWindow.Presenter` |
| `WindowStartupLocation` | Yes | No — calculate manually |
| `Icon` | `Window.Icon` | `AppWindow.SetIcon()` |
| `Title` | `Window.Title` | `AppWindow.Title` (or `Window.Title`) |
| Size (Width/Height) | Yes | No — use `AppWindow.Resize()` |
| Position (Left/Top) | Yes | No — use `AppWindow.Move()` |
| `IsDefault`/`IsCancel` on buttons | Yes | No — handle Enter/Escape in code-behind |
### Getting AppWindow from Window
```csharp
using Microsoft.UI;
using Microsoft.UI.Windowing;
using WinRT.Interop;
IntPtr hwnd = WindowNative.GetWindowHandle(window);
WindowId windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
AppWindow appWindow = AppWindow.GetFromWindowId(windowId);
```
### Pattern: SizeToContent Replacement (from ImageResizer)
WinUI 3 has no `SizeToContent`. ImageResizer implemented a manual equivalent:
```csharp
private void SizeToContent()
{
if (Content is not FrameworkElement content)
return;
// Measure desired content size
content.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
var desiredHeight = content.DesiredSize.Height + WindowChromeHeight + Padding;
// Account for DPI scaling
var scaleFactor = Content.XamlRoot.RasterizationScale;
var pixelHeight = (int)(desiredHeight * scaleFactor);
var pixelWidth = (int)(WindowWidth * scaleFactor);
// Resize via AppWindow
var hwnd = WindowNative.GetWindowHandle(this);
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Resize(new Windows.Graphics.SizeInt32(pixelWidth, pixelHeight));
}
```
**Key details:**
- `WindowChromeHeight` ≈ 32px for the title bar
- Must multiply by `RasterizationScale` for DPI-aware sizing
- Call `SizeToContent()` after page navigation or content changes
- Unsubscribe previous event handlers before subscribing new ones to avoid memory leaks
### Window Positioning (Center Screen)
```csharp
var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Nearest);
var centerX = (displayArea.WorkArea.Width - appWindow.Size.Width) / 2;
var centerY = (displayArea.WorkArea.Height - appWindow.Size.Height) / 2;
appWindow.Move(new Windows.Graphics.PointInt32(centerX, centerY));
```
### Window State (Minimize/Maximize)
```csharp
(appWindow.Presenter as OverlappedPresenter)?.Maximize();
(appWindow.Presenter as OverlappedPresenter)?.Minimize();
(appWindow.Presenter as OverlappedPresenter)?.Restore();
```
### Title Bar Customization
```csharp
// Extend content into title bar
this.ExtendsContentIntoTitleBar = true;
this.SetTitleBar(AppTitleBar); // AppTitleBar is a XAML element
// Or via AppWindow API
if (AppWindowTitleBar.IsCustomizationSupported())
{
var titleBar = appWindow.TitleBar;
titleBar.ExtendsContentIntoTitleBar = true;
titleBar.ButtonBackgroundColor = Colors.Transparent;
}
```
### Tracking the Main Window
```csharp
public partial class App : Application
{
public static Window MainWindow { get; private set; }
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
MainWindow = new MainWindow();
MainWindow.Activate();
}
}
```
### ContentDialog Requires XamlRoot
```csharp
var dialog = new ContentDialog
{
Title = "Confirm",
Content = "Are you sure?",
PrimaryButtonText = "Yes",
CloseButtonText = "No",
XamlRoot = this.Content.XamlRoot // REQUIRED
};
var result = await dialog.ShowAsync();
```
### File Pickers Require HWND
```csharp
var picker = new FileOpenPicker();
picker.FileTypeFilter.Add(".jpg");
// REQUIRED for desktop apps
var hwnd = WindowNative.GetWindowHandle(App.MainWindow);
WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);
var file = await picker.PickSingleFileAsync();
```
### Window Close Handling
```csharp
// WPF
protected override void OnClosing(CancelEventArgs e) { e.Cancel = true; this.Hide(); }
// WinUI 3
this.AppWindow.Closing += (s, e) => { e.Cancel = true; this.AppWindow.Hide(); };
```
---
## Custom Entry Point (DISABLE_XAML_GENERATED_MAIN)
ImageResizer uses a custom `Program.cs` entry point instead of the WinUI 3 auto-generated `Main`. This is needed for:
- CLI mode (process files without showing UI)
- Custom initialization before the WinUI 3 App starts
- Single-instance enforcement
### Setup
In `.csproj`:
```xml
<DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants>
```
Create `Program.cs`:
```csharp
public static class Program
{
[STAThread]
public static int Main(string[] args)
{
if (args.Length > 0)
{
// CLI mode — no UI
return RunCli(args);
}
// GUI mode
WinRT.ComWrappersSupport.InitializeComWrappers();
Application.Start((p) =>
{
var context = new DispatcherQueueSynchronizationContext(
DispatcherQueue.GetForCurrentThread());
SynchronizationContext.SetSynchronizationContext(context);
_ = new App();
});
return 0;
}
}
```
### WPF App Constructor Removal
WPF modules often created `new App()` to initialize the WPF `Application` and get `Application.Current.Dispatcher`. This is no longer needed — the WinUI 3 `Application.Start()` handles this.
```csharp
// DELETE (WPF pattern):
_imageResizerApp = new App();
// REPLACE with: Store DispatcherQueue explicitly (see Global DispatcherQueue Access above)
```

View File

@@ -1,663 +0,0 @@
# XAML Migration Guide
Detailed reference for migrating XAML from WPF to WinUI 3, based on the ImageResizer migration.
## XML Namespace Declaration Changes
### Before (WPF)
```xml
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MyApp"
xmlns:m="clr-namespace:ImageResizer.Models"
xmlns:p="clr-namespace:ImageResizer.Properties"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
x:Class="MyApp.MainWindow">
```
### After (WinUI 3)
```xml
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MyApp"
xmlns:m="using:ImageResizer.Models"
xmlns:converters="using:ImageResizer.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Class="MyApp.MainWindow">
```
### Key Changes
| WPF Syntax | WinUI 3 Syntax | Notes |
|------------|---------------|-------|
| `clr-namespace:Foo` | `using:Foo` | CLR namespace mapping |
| `clr-namespace:Foo;assembly=Bar` | `using:Foo` | Assembly qualification not needed |
| `xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"` | **Remove entirely** | WPF-UI namespace no longer needed |
| `xmlns:p="clr-namespace:...Properties"` | **Remove** | No more `.resx` string bindings |
| `sys:String` (from mscorlib) | `x:String` | XAML intrinsic types |
| `sys:Int32` | `x:Int32` | XAML intrinsic types |
| `sys:Boolean` | `x:Boolean` | XAML intrinsic types |
| `sys:Double` | `x:Double` | XAML intrinsic types |
## Unsupported Markup Extensions
| WPF Markup Extension | WinUI 3 Alternative |
|----------------------|---------------------|
| `{DynamicResource Key}` | `{ThemeResource Key}` (theme-reactive) or `{StaticResource Key}` |
| `{x:Static Type.Member}` | `{x:Bind}` to a static property, or code-behind |
| `{x:Type local:MyType}` | Not supported; use code-behind |
| `{x:Array}` | Not supported; create collections in code-behind |
| `{x:Code}` | Not supported |
### DynamicResource → ThemeResource
```xml
<!-- WPF -->
<TextBlock Foreground="{DynamicResource MyBrush}" />
<!-- WinUI 3 -->
<TextBlock Foreground="{ThemeResource MyBrush}" />
```
`ThemeResource` automatically updates when the app theme changes (Light/Dark/HighContrast). For truly dynamic non-theme resources, set values in code-behind or use data binding.
### x:Static Resource Strings → x:Uid
This is the most pervasive XAML change. WPF used `{x:Static}` to bind to strongly-typed `.resx` resource strings. WinUI 3 uses `x:Uid` with `.resw` files.
**WPF:**
```xml
<Button Content="{x:Static p:Resources.Cancel}" />
<TextBlock Text="{x:Static p:Resources.Input_Header}" />
```
**WinUI 3:**
```xml
<Button x:Uid="Cancel" />
<TextBlock x:Uid="Input_Header" />
```
In `Strings/en-us/Resources.resw`:
```xml
<data name="Cancel.Content" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="Input_Header.Text" xml:space="preserve">
<value>Select a size</value>
</data>
```
The `x:Uid` suffix (`.Content`, `.Text`, `.Header`, `.PlaceholderText`, etc.) matches the target property name.
### DataType with x:Type → Remove
**WPF:**
```xml
<DataTemplate DataType="{x:Type m:ResizeSize}">
```
**WinUI 3:**
```xml
<DataTemplate x:DataType="m:ResizeSize">
```
## WPF-UI (Lepo) Controls Removal
If the module uses the `WPF-UI` library, replace all Lepo controls with native WinUI 3 equivalents.
### Window
```xml
<!-- WPF (WPF-UI) -->
<ui:FluentWindow
ExtendsContentIntoTitleBar="True"
WindowStartupLocation="CenterScreen">
<ui:TitleBar Title="Image Resizer" />
...
</ui:FluentWindow>
<!-- WinUI 3 (native) -->
<Window>
<!-- Title bar managed via code-behind: this.ExtendsContentIntoTitleBar = true; -->
...
</Window>
```
#### Recommended: Use WindowEx from WinUIEx
> **Tip:** Prefer `WinUIEx.WindowEx` over bare `Window`. It restores many WPF-like window properties directly in XAML, avoiding boilerplate code-behind for common windowing tasks.
```xml
<!-- WinUI 3 with WindowEx (preferred in PowerToys) -->
<winuiex:WindowEx
xmlns:winuiex="using:WinUIEx"
x:Class="MyApp.MainWindow"
MinWidth="480"
MinHeight="320"
IsShownInSwitchers="True"
IsTitleBarVisible="True">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid>
...
</Grid>
</winuiex:WindowEx>
```
Properties available on `WindowEx` that mirror WPF `Window`:
| WPF Window Property | WindowEx Property | Notes |
|---------------------|-------------------|-------|
| `MinWidth` / `MinHeight` | `MinWidth` / `MinHeight` | Set directly in XAML |
| `Width` / `Height` | `Width` / `Height` | Initial window size |
| `WindowState` | `WindowState` | Minimized, Maximized, Normal |
| `Title` | `Title` or `x:Uid` | Window title |
| `Icon` | Use `TitleBar.IconSource` | Via WinUI TitleBar control |
| `ShowInTaskbar` | `IsShownInSwitchers` | Alt-Tab visibility |
| `TopMost` | `IsAlwaysOnTop` | Always-on-top window |
NuGet: `WinUIEx` — already referenced by most PowerToys modules.
#### Recommended: Page-in-Window Architecture
> **Tip:** WinUI 3 `Window` is NOT a `FrameworkElement` — it does not support `Resources`, `DataContext`, `x:Bind`, or `VisualStateManager` directly. Place a `Page` as the Window's root content to regain these WPF-like capabilities.
```xml
<!-- WinUI 3 — Window contains a Page for full FrameworkElement support -->
<winuiex:WindowEx x:Class="MyApp.MainWindow"
xmlns:winuiex="using:WinUIEx"
xmlns:views="using:MyApp.Views">
<views:MainPage x:Name="mainPage" />
</winuiex:WindowEx>
```
```xml
<!-- MainPage.xaml — has full FrameworkElement capabilities -->
<Page x:Class="MyApp.Views.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<Page.Resources>
<!-- Resources work here (unlike on Window) -->
<SolidColorBrush x:Key="MyBrush" Color="Red"/>
</Page.Resources>
<Grid>
<VisualStateManager.VisualStateGroups>
<!-- VisualStateManager works here (unlike on Window) -->
</VisualStateManager.VisualStateGroups>
...
</Grid>
</Page>
```
This is the standard pattern in PowerToys (e.g., FileLocksmith, EnvironmentVariables).
### App.xaml Resources
```xml
<!-- WPF (WPF-UI) -->
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark" />
<ui:ControlsDictionary />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<!-- WinUI 3 (native) -->
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
```
### CommunityToolkit.WinUI — WPF Replacement Controls
> **Tip:** The `CommunityToolkit.WinUI` package provides many controls and helpers familiar to WPF developers that are missing from WinUI 3 out of the box. Before writing custom replacements, check whether CommunityToolkit already provides what you need.
Key packages (XAML namespace is `using:CommunityToolkit.WinUI.Controls` for the `Controls.*` family):
- **`CommunityToolkit.WinUI.Controls.Primitives`** — `WrapPanel`, `UniformGrid`, `DockPanel`, `ConstrainedBox`, `SwitchPresenter`
- **`CommunityToolkit.WinUI.Controls.HeaderedControls`** — `HeaderedContentControl`, `HeaderedItemsControl`, `HeaderedTreeView`
- **`CommunityToolkit.WinUI.Controls.SettingsControls`** — `SettingsCard`, `SettingsExpander`
- **`CommunityToolkit.WinUI.Controls.Sizers`** — `GridSplitter`, `PropertySizer`, `ContentSizer`
- **`CommunityToolkit.WinUI.UI.Controls.DataGrid`** — legacy v7 `DataGrid` (no longer maintained); prefer [`WinUI.TableView`](https://github.com/w-ahmad/WinUI.TableView) for new work
- **`CommunityToolkit.WinUI.Converters`** — Common value converters (`BoolToVisibilityConverter`, `StringFormatConverter`, etc.)
- **`CommunityToolkit.WinUI.Behaviors`** — XAML behaviors for animations and interactions
- **`CommunityToolkit.WinUI.Extensions`** — Extension methods for WinUI types
### Common Control Replacements
```xml
<!-- WPF-UI NumberBox -->
<ui:NumberBox Value="{Binding Width}" />
<!-- WinUI 3 -->
<NumberBox Value="{x:Bind ViewModel.Width, Mode=TwoWay}" />
<!-- WPF-UI InfoBar -->
<ui:InfoBar Title="Warning" Message="..." IsOpen="True" Severity="Warning" />
<!-- WinUI 3 -->
<InfoBar Title="Warning" Message="..." IsOpen="True" Severity="Warning" />
<!-- WPF-UI ProgressRing -->
<ui:ProgressRing IsIndeterminate="True" />
<!-- WinUI 3 -->
<ProgressRing IsActive="True" />
<!-- WPF-UI SymbolIcon -->
<ui:SymbolIcon Symbol="Add" />
<!-- WinUI 3 -->
<SymbolIcon Symbol="Add" />
```
### Button Patterns
```xml
<!-- WPF -->
<Button IsDefault="True" Content="OK" />
<Button IsCancel="True" Content="Cancel" />
<!-- WinUI 3 (no IsDefault/IsCancel) -->
<Button Style="{StaticResource AccentButtonStyle}" Content="OK" />
<Button Content="Cancel" />
<!-- Handle Enter/Escape keys in code-behind if needed -->
```
## No-Equivalent Patterns (Requires Architectural Rework)
These WPF features demand design changes, not find-and-replace. Read this section BEFORE attempting to migrate any file that uses these patterns.
### MultiBinding → x:Bind Function Binding
WinUI does not support `MultiBinding`. Replace with `x:Bind` function binding (most direct replacement), a computed ViewModel property, or multiple simple bindings.
**WPF:**
```xml
<TextBlock>
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} {1}">
<Binding Path="FirstName" />
<Binding Path="LastName" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
```
**WinUI 3:**
```xml
<TextBlock Text="{x:Bind local:Converters.FormatFullName(ViewModel.FirstName, ViewModel.LastName), Mode=OneWay}" />
```
```csharp
public static class Converters
{
public static string FormatFullName(string first, string last) => $"{first} {last}";
}
```
### Adorners → Context-Dependent Replacements
WPF's `AdornerLayer` has no WinUI equivalent. Choose replacement by use case:
| Adorner Use Case | WinUI 3 Replacement |
|------------------|---------------------|
| Validation indicators | `TeachingTip`, `InfoBar`, or InputValidation templates |
| Resize handles | `Popup` positioned relative to target |
| Drag preview | `DragItemsStarting` event with custom DragUI |
| Overlay decorations | Canvas overlay or Popup layer |
| Watermark / Placeholder | `TextBox.PlaceholderText` (built-in) |
### RoutedUICommand → ICommand / RelayCommand
WinUI does not support routed commands or `CommandBinding`. Replace with standard `ICommand` pattern:
```csharp
// CommunityToolkit.Mvvm
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save() { /* save logic */ }
private bool CanSave() => IsDirty;
```
WinUI 3 also provides `StandardUICommand` and `XamlUICommand` for pre-defined platform commands (Cut, Copy, Paste, Delete) with built-in icons and keyboard accelerators.
### Tunneling / Preview Events
WinUI has no tunneling event model. `PreviewMouseDown`, `PreviewKeyDown`, etc. do not exist.
- Replace with the bubbling equivalent (`PointerPressed`, `KeyDown`)
- If you relied on tunneling to intercept events before children, restructure using the `Handled` property
- For must-handle scenarios, use `AddHandler` with `handledEventsToo: true`:
```csharp
myElement.AddHandler(UIElement.PointerPressedEvent,
new PointerEventHandler(OnPointerPressed), handledEventsToo: true);
```
## Style and Template Changes
### Implicit Styles — Always Use BasedOn
> **Warning:** In WinUI 3, always use `BasedOn` when overriding default control styles. Without it, your style **replaces the entire default style** rather than extending it.
```xml
<!-- WRONG — replaces entire default style, control may lose all visual appearance -->
<Style TargetType="Button">
<Setter Property="Background" Value="Red" />
</Style>
<!-- CORRECT — extends the default style -->
<Style TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
<Setter Property="Background" Value="Red" />
</Style>
```
### Triggers → VisualStateManager
WPF `Triggers`, `DataTriggers`, and `EventTriggers` are not supported. Two replacement approaches:
#### Approach 1: StateTrigger (direct DataTrigger replacement — simpler)
Use this for data-driven state changes. This is the closest equivalent to WPF `DataTrigger`:
**WPF:**
```xml
<Style TargetType="Border">
<Style.Triggers>
<DataTrigger Binding="{Binding IsActive}" Value="True">
<Setter Property="Background" Value="Green" />
</DataTrigger>
</Style.Triggers>
</Style>
```
**WinUI 3:**
```xml
<Border x:Name="MyBorder">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup>
<VisualState x:Name="Active">
<VisualState.StateTriggers>
<StateTrigger IsActive="{x:Bind ViewModel.IsActive, Mode=OneWay}" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="MyBorder.Background" Value="Green" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Border>
```
Note: `VisualStateManager` must be placed on a control inside the Window, NOT on the Window itself.
#### Approach 2: ControlTemplate (for property triggers like IsMouseOver)
Use this when replacing `<Trigger Property="IsMouseOver">` or similar control-state triggers:
**WPF:**
```xml
<Style TargetType="Button">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="LightBlue"/>
</Trigger>
</Style.Triggers>
</Style>
```
**WinUI 3:**
```xml
<Style TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid x:Name="RootGrid" Background="{TemplateBinding Background}">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="LightBlue"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<ContentPresenter />
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
```
### No Binding in Setter.Value
```xml
<!-- WPF (works) -->
<Setter Property="Foreground" Value="{Binding TextColor}"/>
<!-- WinUI 3 (does NOT work — use StaticResource) -->
<Setter Property="Foreground" Value="{StaticResource TextColorBrush}"/>
```
### Visual State Name Changes
| WPF | WinUI 3 |
|-----|---------|
| `MouseOver` | `PointerOver` |
| `Disabled` | `Disabled` |
| `Pressed` | `Pressed` |
## Visibility.Hidden — No Equivalent
WinUI only has `Visible` and `Collapsed`. There is no `Hidden`.
| WPF | WinUI 3 | Behavior |
|-----|---------|----------|
| `Visibility.Visible` | `Visibility.Visible` | Rendered and occupies layout space |
| `Visibility.Hidden` | **Not available** | Use `Opacity="0"` with `Visibility="Visible"` to hide but keep layout |
| `Visibility.Collapsed` | `Visibility.Collapsed` | Not rendered, no layout space |
## Resource Dictionary Changes
### XamlControlsResources Must Be First
> **Warning:** `XamlControlsResources` must be the **first** merged dictionary in `App.xaml`. It provides the default Fluent styles. Omitting it gives you controls with no visual appearance. Resource paths use `ms-appx:///` instead of relative paths.
```xml
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- MUST be first -->
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Then your custom dictionaries -->
<ResourceDictionary Source="ms-appx:///Styles/Colors.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
```
### Window.Resources → Grid.Resources (or use Page)
WinUI 3 `Window` is NOT a `FrameworkElement` — no `Window.Resources`, `DataContext`, or `VisualStateManager`.
**Preferred approach:** Use the [Page-in-Window architecture](#recommended-page-in-window-architecture) described above. A `Page` inside the `Window` gives you full `FrameworkElement` capabilities (Resources, DataContext, x:Bind, VisualStateManager).
**Fallback** (for simple windows without a Page):
```xml
<!-- WPF -->
<Window>
<Window.Resources>
<SolidColorBrush x:Key="MyBrush" Color="Red"/>
</Window.Resources>
<Grid>...</Grid>
</Window>
<!-- WinUI 3 -->
<Window>
<Grid>
<Grid.Resources>
<SolidColorBrush x:Key="MyBrush" Color="Red"/>
</Grid.Resources>
...
</Grid>
</Window>
```
### Theme Dictionaries
```xml
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="MyBrush" Color="#FF000000"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="MyBrush" Color="#FFFFFFFF"/>
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast">
<SolidColorBrush x:Key="MyBrush" Color="{ThemeResource SystemColorWindowTextColor}"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
```
## URI Scheme Changes
| WPF | WinUI 3 |
|-----|---------|
| `pack://application:,,,/MyAssembly;component/image.png` | `ms-appx:///Assets/image.png` |
| `pack://application:,,,/image.png` | `ms-appx:///image.png` |
| Relative path `../image.png` | `ms-appx:///image.png` |
Assets directory convention: `Resources/``Assets/<Module>/`
## Data Binding Changes
### {Binding} vs {x:Bind}
Both are available. Prefer `{x:Bind}` for compile-time safety and performance.
| Feature | `{Binding}` | `{x:Bind}` |
|---------|------------|------------|
| Default mode | `OneWay` | **`OneTime`** (explicit `Mode=OneWay` required!) |
| Context | `DataContext` | Code-behind class |
| Resolution | Runtime | Compile-time |
| Performance | Reflection-based | Compiled |
| Function binding | No | Yes |
### Binding Differences from WPF
These WPF binding patterns behave differently in WinUI 3 — review on a case-by-case basis rather than mechanically removing.
```xml
<!-- UpdateSourceTrigger: limited support in WinUI 3 -->
<TextBox Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}" />
<!-- WinUI 3: UpdateSourceTrigger=LostFocus does NOT exist; PropertyChanged is the TextBox default.
Prefer x:Bind, which binds with PropertyChanged semantics by default for TwoWay. -->
<TextBox Text="{x:Bind ViewModel.Value, Mode=TwoWay}" />
<!-- RelativeSource: Self and TemplatedParent ARE supported in WinUI 3 -->
<TextBlock Text="{Binding Tag, RelativeSource={RelativeSource Self}}" />
<!-- Works in WinUI 3. FindAncestor mode is NOT supported — use ElementName, x:Bind,
or the CommunityToolkit FrameworkElementExtensions.Ancestor attached property to reach ancestors. -->
<!-- {Binding} empty path: works in WinUI 3 (binds to the current DataContext) -->
<ItemsControl ItemsSource="{Binding}" />
<!-- This is valid. Note: x:Bind requires an explicit path — there is no empty-path x:Bind. -->
<ItemsControl ItemsSource="{x:Bind ViewModel.Items}" />
```
## WPF-Only Window Properties to Remove
These properties exist on WPF `Window` but not WinUI 3:
```xml
<!-- Remove from XAML — handle in code-behind via AppWindow API -->
SizeToContent="Height"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
ExtendsContentIntoTitleBar="True" <!-- Set in code-behind -->
```
## XAML Control Property Changes
| WPF Property | WinUI 3 Property | Notes |
|-------------|-----------------|-------|
| `Focusable` | `IsTabStop` | Different name |
| `SnapsToDevicePixels` | Not available | WinUI handles pixel snapping internally |
| `UseLayoutRounding` | `UseLayoutRounding` | Same |
| `IsHitTestVisible` | `IsHitTestVisible` | Same |
| `TextBox.VerticalScrollBarVisibility` | `ScrollViewer.VerticalScrollBarVisibility` (attached) | Attached property |
## Complete Find-and-Replace Reference
Use this table for mechanical batch translation. Apply these rules consistently to every file.
### XAML Attribute Replacements
| Find | Replace With | Context |
|------|-------------|---------|
| `ContextMenu=` | `ContextFlyout=` | On any UIElement |
| `{DynamicResource ` | `{ThemeResource ` | Theme-responsive references |
| `{x:Static prefix:Resources.Key}` | `x:Uid="Key"` (with `.resw`) | Resource string — most common WPF case; mechanical `{x:Bind}` will NOT compile here |
| `{x:Static prefix:Type.Member}` | `{x:Bind prefix:Type.Member}` | Static field/property reference (function binding) |
| `Visibility="Hidden"` | `Visibility="Collapsed"` | Or use `Opacity="0"` for layout |
| `MouseLeftButtonDown` | `PointerPressed` | Event handlers |
| `MouseLeftButtonUp` | `PointerReleased` | Event handlers |
| `MouseRightButtonDown` | `RightTapped` | Or `PointerPressed` + check `IsRightButtonPressed` |
| `MouseRightButtonUp` | `PointerReleased` + check `IsRightButtonPressed` | No direct WinUI event; use `RightTapped` only for context-menu-open semantics |
| `MouseEnter` | `PointerEntered` | Event handlers |
| `MouseLeave` | `PointerExited` | Event handlers |
| `MouseMove` | `PointerMoved` | Event handlers |
| `MouseWheel` | `PointerWheelChanged` | Event handlers |
| `MouseDoubleClick` | `DoubleTapped` | Event handlers |
| `PreviewMouseDown` | `PointerPressed` | No tunneling in WinUI — remove Preview |
| `PreviewMouseUp` | `PointerReleased` | No tunneling in WinUI — remove Preview |
| `PreviewKeyDown` | `KeyDown` | No tunneling in WinUI — remove Preview |
| `PreviewKeyUp` | `KeyUp` | No tunneling in WinUI — remove Preview |
| `PreviewMouseWheel` | `PointerWheelChanged` | No tunneling in WinUI — remove Preview |
| `Focusable="True"` | `IsTabStop="True"` | Focus behavior |
| `Focusable="False"` | `IsTabStop="False"` | Focus behavior |
| `TextWrapping="WrapWithOverflow"` | `TextWrapping="Wrap"` | TextBlock, TextBox |
| `MediaElement` | `MediaPlayerElement` | Media playback |
| `clr-namespace:` | `using:` | xmlns declarations |
| `;assembly=` | (remove) | Assembly qualification not needed |
### Code-Behind Replacements
| Find | Replace With |
|------|-------------|
| `using System.Windows;` | `using Microsoft.UI.Xaml;` |
| `using System.Windows.Controls;` | `using Microsoft.UI.Xaml.Controls;` |
| `using System.Windows.Media;` | `using Microsoft.UI.Xaml.Media;` |
| `using System.Windows.Data;` | `using Microsoft.UI.Xaml.Data;` |
| `using System.Windows.Input;` | `using Microsoft.UI.Xaml.Input;` |
| `using System.Windows.Threading;` | `using Microsoft.UI.Dispatching;` |
| `using System.Windows.Shapes;` | `using Microsoft.UI.Xaml.Shapes;` |
| `using System.Windows.Markup;` | `using Microsoft.UI.Xaml.Markup;` |
| `using System.Windows.Automation;` | `using Microsoft.UI.Xaml.Automation;` |
| `using System.Windows.Media.Animation;` | `using Microsoft.UI.Xaml.Media.Animation;` |
| `using System.Windows.Documents;` | `using Microsoft.UI.Xaml.Documents;` |
| `using System.Windows.Navigation;` | `using Microsoft.UI.Xaml.Navigation;` |
| `Dispatcher.Invoke(` | `DispatcherQueue.TryEnqueue(` |
| `Dispatcher.BeginInvoke(` | `DispatcherQueue.TryEnqueue(` |
| `Dispatcher.CheckAccess()` | `DispatcherQueue.HasThreadAccess` |
| `MouseEventArgs` | `PointerRoutedEventArgs` |
| `KeyEventArgs` | `KeyRoutedEventArgs` |
| `RoutedUICommand` | `RelayCommand` (CommunityToolkit.Mvvm) |
| `CommandBinding` | Remove; bind ICommand directly |
## XAML Formatting (XamlStyler)
After migration, run XamlStyler to normalize formatting:
- Alphabetize xmlns declarations and element attributes
- Add UTF-8 BOM to all XAML files
- Normalize comment spacing: `<!-- text -->``<!-- text -->`
PowerToys command: `.\.pipelines\applyXamlStyling.ps1 -Main`

View File

@@ -1,240 +0,0 @@
name: Automatic Triaging on Issue/PR Creation
on:
issues:
types: [opened, reopened]
pull_request_target:
types: [opened, reopened, synchronize]
# Manual trigger: go to Actions → "Automatic Triaging on Issue Creation" → Run workflow.
# Enter one or more comma-separated issue numbers (e.g. "1234" or "1234,1235,1236")
# to apply AI-generated area labels to existing untriaged issues.
workflow_dispatch:
inputs:
issue_numbers:
description: 'Comma-separated issue number(s) to label (e.g. 1234 or 1234,1235)'
required: true
permissions:
models: read
issues: write
pull-requests: write
concurrency:
# Each workflow run gets its own concurrency group.
# For issue events, group by issue number so a rapid close+reopen only runs once.
# For pull request events, group by PR number so rapid updates coalesce.
# For manual dispatch (which may cover multiple issues), use the unique run ID.
group: ${{ github.event_name == 'issues' && format('{0}-issues-{1}', github.workflow, github.event.issue.number) || github.event_name == 'pull_request_target' && format('{0}-pr-{1}', github.workflow, github.event.pull_request.number) || github.run_id }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Apply area labels with AI
uses: actions/github-script@v9
env:
# actions/github-script does not propagate `github-token` to
# process.env. Expose it explicitly so the inline script can
# authenticate against the GitHub Models inference endpoint.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// When triggered manually, process each supplied issue number in turn.
// When triggered by an issue or PR event, use the event's number.
let issueNumbers;
if (context.eventName === 'workflow_dispatch') {
issueNumbers = String(context.payload.inputs.issue_numbers)
.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => Number.isFinite(n) && n > 0);
} else {
issueNumbers = [context.issue.number];
}
if (issueNumbers.length === 0) {
console.log('No valid issue numbers to process; skipping.');
return;
}
for (const issueNumber of issueNumbers) {
console.log(`\n--- Processing item #${issueNumber} ---`);
await labelIssue(issueNumber);
}
async function labelIssue(issueNumber) {
// Fetch as an issue resource; PRs are represented by issues with a pull_request field.
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const itemType = issue.pull_request ? 'Pull request' : 'Issue';
// Skip pull requests that already have labels applied.
if (issue.pull_request && issue.labels && issue.labels.length > 0) {
const existingLabels = issue.labels.map(l => l.name).join(', ');
console.log(`${itemType} #${issueNumber} already has labels (${existingLabels}); skipping.`);
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';
if (!title && !body) {
console.log(`${itemType} #${issueNumber} has no title or body; skipping.`);
return;
}
// Truncation limit for issue body sent to the model. Keeps the
// prompt within the model's context window and avoids high token usage.
const MAX_BODY_LENGTH = 4000;
// Upper bound on model response tokens. A JSON array of label strings
// is compact; 200 tokens is more than enough for any realistic response.
const MAX_TOKENS = 200;
// All valid Product-* and Area-* labels the agent may choose from.
const VALID_LABELS = [
'Product-Advanced Paste',
'Product-Always On Top',
'Product-Awake',
'Product-Color Picker',
'Product-CommandNotFound',
'Product-Command Palette',
'Product-CropAndLock',
'Product-Environment Variables',
'Product-FancyZones',
'Product-File Explorer',
'Product-File Locksmith',
'Product-Find My Mouse',
'Product-Grab And Move',
'Product-Hosts File Editor',
'Product-Image Resizer',
'Product-Keyboard Manager',
'Product-LightSwitch',
'Product-Mouse Highlighter',
'Product-Mouse Jump',
'Product-Mouse Pointer Crosshairs',
'Product-Mouse Utilities',
'Product-Mouse Without Borders',
'Product-New+',
'Product-Peek',
'Product-PowerDisplay',
'Product-PowerRename',
'Product-PowerToys Run',
'Product-Quick Accent',
'Product-Registry Preview',
'Product-Screen Ruler',
'Product-Settings',
'Product-Shortcut Guide',
'Product-Text Extractor',
'Product-Workspaces',
'Product-ZoomIt',
'Area-Setup/Install',
'Area-Localization',
];
const systemPrompt = `You are a GitHub triage assistant for the microsoft/PowerToys repository.
Your job is to classify issues and pull requests by assigning the correct area label(s).
Rules:
- Only return labels from the following list, exactly as written:
${VALID_LABELS.map(l => ` • ${l}`).join('\n')}
- Choose only the labels that clearly match the issue content.
- If the issue mentions multiple areas, include a label for each one.
- If no label fits, return an empty array.
- Respond with ONLY a JSON array of label strings, no explanation.
Example: ["Product-FancyZones","Product-Settings"]`;
const userPrompt = `${itemType} title: ${title}
${itemType} body:
${body.slice(0, MAX_BODY_LENGTH)}`;
// Validate that the token is available before making the API call.
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.log('GITHUB_TOKEN is not set; skipping.');
return;
}
// Call the GitHub Models inference endpoint (OpenAI-compatible).
const response = await fetch(
'https://models.inference.ai.azure.com/chat/completions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: MAX_TOKENS,
// temperature: 0 ensures deterministic, consistent label
// classification across similar issues.
temperature: 0,
}),
}
);
if (!response.ok) {
const errorBody = await response.text();
console.log(`GitHub Models API error: ${response.status} ${response.statusText} — ${errorBody}`);
return;
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content?.trim() ?? '';
console.log(`Model response: ${text}`);
let suggested;
try {
suggested = JSON.parse(text);
} catch {
console.log('Could not parse model response as JSON; skipping.');
return;
}
if (!Array.isArray(suggested) || suggested.length === 0) {
console.log('No labels suggested by the model.');
return;
}
// Only apply labels that are in the allow-list.
const validSet = new Set(VALID_LABELS);
const toApply = [...new Set(suggested.filter(l => validSet.has(l)))];
if (toApply.length === 0) {
console.log('Model returned no valid labels.');
return;
}
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: toApply,
});
console.log(`${itemType} #${issueNumber}: added labels: ${toApply.join(', ')}`);
} catch (error) {
// Some contexts (for example, restricted integrations) can deny
// label writes even when workflow permissions request write scope.
// Skip without failing the entire triage workflow.
const status = error?.status;
const message = error?.message ?? String(error);
if (status === 403 && message.includes('Resource not accessible by integration')) {
console.log(`${itemType} #${issueNumber}: skipping label write due to restricted token context (403).`);
return;
}
throw error;
}
}

View File

@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: 'Dependency Review'
uses: actions/dependency-review-action@v5
uses: actions/dependency-review-action@v4

View File

@@ -27,7 +27,7 @@ jobs:
issue: ${{ fromJson(github.event.inputs.issue_numbers) }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Run GenAI Issue Deduplicator
uses: pelikhan/action-genai-issue-dedup@v0

View File

@@ -23,7 +23,7 @@ jobs:
export $(echo 'anypass_just_to_unlock' | gnome-keyring-daemon --start --components=gpg,pkcs11,secrets,ssh)
- name: Log in to Azure
uses: azure/login@v3
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -47,7 +47,7 @@ jobs:
- uses: microsoft/setup-msstore-cli@v1
- name: Fetch Store Credential
uses: azure/cli@v3
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |-

View File

@@ -1,67 +0,0 @@
# 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

View File

@@ -55,7 +55,7 @@ name: Spell checking
# spelling:
# # remove `security-events: write` and `use_sarif: 1`
# # remove `experimental_apply_changes_via_bot: 1`
# ... otherwise, adjust the `with:` as you wish
# ... otherwise adjust the `with:` as you wish
on:
push:
@@ -74,8 +74,6 @@ on:
types:
- "created"
permissions: {}
jobs:
spelling:
name: Check Spelling
@@ -87,7 +85,7 @@ jobs:
outputs:
followup: ${{ steps.spelling.outputs.followup }}
runs-on: ubuntu-latest
if: ${{ (contains(github.event_name, 'pull_request') && github.event.pull_request.state == 'open') || github.event_name == 'push' }}
if: ${{ contains(github.event_name, 'pull_request') || github.event_name == 'push' }}
concurrency:
group: spelling-${{ github.event.pull_request.number || github.ref }}
# note: If you use only_check_changed_files, you do not want cancel-in-progress
@@ -95,7 +93,7 @@ jobs:
steps:
- name: check-spelling
id: spelling
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
with:
config: .github/actions/spell-check
suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }}
@@ -137,12 +135,11 @@ jobs:
cspell:cpp/compiler-msvc.txt
cspell:python/common/extra.txt
cspell:scala/scala.txt
ignored: ignored-expect-variant
comment-push:
name: Report (Push)
# If your workflow isn't running on push, you can remove this job
runs-on: ubuntu-slim
runs-on: ubuntu-latest
needs: spelling
permissions:
actions: read
@@ -150,23 +147,30 @@ jobs:
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
steps:
- name: comment
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
with:
config: .github/actions/spell-check
checkout: true
spell_check_this: microsoft/PowerToys@main
task: ${{ needs.spelling.outputs.followup }}
comment-pr:
name: Report (PR)
# If you workflow isn't running on pull_request*, you can remove this job
runs-on: ubuntu-slim
runs-on: ubuntu-latest
needs: spelling
permissions:
actions: read
contents: read
pull-requests: write
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
steps:
- name: comment
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
with:
config: .github/actions/spell-check
checkout: true
spell_check_this: check-spelling/spell-check-this@prerelease
task: ${{ needs.spelling.outputs.followup }}
experimental_apply_changes_via_bot: ${{ github.repository_owner != 'microsoft' && 1 }}
@@ -176,13 +180,12 @@ jobs:
contents: write
pull-requests: write
actions: read
runs-on: ubuntu-slim
runs-on: ubuntu-latest
if: ${{
github.repository_owner != 'microsoft' &&
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@check-spelling-bot') &&
contains(github.event.comment.body, 'apply') &&
contains(github.event.comment.body, '@check-spelling-bot apply') &&
contains(github.event.comment.body, 'https://')
}}
concurrency:
@@ -190,7 +193,7 @@ jobs:
cancel-in-progress: false
steps:
- name: apply spelling updates
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
with:
experimental_apply_changes_via_bot: ${{ github.repository_owner != 'microsoft' && 1 }}
checkout: true

View File

@@ -1,35 +0,0 @@
# NOTE: This workflow depends on .github/scripts/telemetry-pr-check.js for telemetry detection and PR comments.
# Keep this workflow and script behavior in sync when making changes.
name: Telemetry PR Check
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
workflow_dispatch:
inputs:
pr_number:
description: "Pull Request Number to test against"
required: true
type: string
permissions:
contents: read
pull-requests: write
concurrency:
group: telemetry-pr-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
detect-telemetry-events:
if: ${{ github.event.pull_request.draft == false }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Detect telemetry event changes and comment PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/telemetry-pr-check.js

21
.gitignore vendored
View File

@@ -19,9 +19,6 @@
[Rr]eleases/
x64/
x86/
!**/rnnoise/
!**/rnnoise/x86/
!**/rnnoise/x86/**
ARM64/
bld/
[Bb]in/
@@ -363,21 +360,3 @@ src/common/Telemetry/*.etl
# PowerToysInstaller Build Temp Files
installer/*/*.wxs.bk
/src/modules/awake/.claude
# Claude AI local settings - local-only, not committed
**/.claude/settings.local.json
# Squad / Copilot agents — local-only, not committed
.copilot
.squad
.squad-workstream
.github/agents/**squad**.md
.github/workflows/**squad**.yml
# vcpkg manifest mode installed packages
vcpkg_installed/
deps/vcpkg/
# Superpowers-generated docs (specs, design, plans) — local-only, not committed
docs/superpowers/

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "deps/spdlog"]
path = deps/spdlog
url = https://github.com/gabime/spdlog.git
[submodule "deps/expected-lite"]
path = deps/expected-lite
url = https://github.com/martinmoene/expected-lite.git

View File

@@ -18,7 +18,6 @@
"StylesReportTool\\PowerToys.StylesReportTool.exe",
"CalculatorEngineCommon.dll",
"PowerToys.Common.UI.Controls.dll",
"PowerToys.ManagedTelemetry.dll",
"PowerToys.ManagedCommon.dll",
"PowerToys.ManagedCsWin32.dll",
@@ -106,13 +105,7 @@
"PowerToys.SvgThumbnailProvider.dll",
"PowerToys.SvgThumbnailProvider.exe",
"PowerToys.SvgThumbnailProviderCpp.dll",
"PowerToys.KeyboardManager.dll",
"KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe",
"WinUI3Apps\\PowerToys.KeyboardManagerEditorUI.exe",
"WinUI3Apps\\PowerToys.KeyboardManagerEditorUI.dll",
"KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe",
"PowerToys.KeyboardManagerEditorLibraryWrapper.dll",
"WinUI3Apps\\PowerToys.HostsModuleInterface.dll",
"WinUI3Apps\\PowerToys.HostsUILib.dll",
"WinUI3Apps\\PowerToys.Hosts.dll",
@@ -141,13 +134,13 @@
"WinUI3Apps\\PowerToys.EnvironmentVariables.dll",
"WinUI3Apps\\PowerToys.EnvironmentVariables.exe",
"WinUI3Apps\\PowerToys.ImageResizer.exe",
"WinUI3Apps\\PowerToys.ImageResizer.dll",
"PowerToys.ImageResizer.exe",
"PowerToys.ImageResizer.dll",
"WinUI3Apps\\PowerToys.ImageResizerCLI.exe",
"WinUI3Apps\\PowerToys.ImageResizerCLI.dll",
"WinUI3Apps\\PowerToys.ImageResizerExt.dll",
"WinUI3Apps\\PowerToys.ImageResizerContextMenu.dll",
"WinUI3Apps\\ImageResizerContextMenuPackage.msix",
"PowerToys.ImageResizerExt.dll",
"PowerToys.ImageResizerContextMenu.dll",
"ImageResizerContextMenuPackage.msix",
"PowerToys.LightSwitchModuleInterface.dll",
"LightSwitchService\\PowerToys.LightSwitchService.exe",
@@ -211,18 +204,11 @@
"WinUI3Apps\\NewPlusPackage.msix",
"WinUI3Apps\\PowerToys.NewPlus.ShellExtension.win10.dll",
"WinUI3Apps\\PowerAccent.Core.dll",
"WinUI3Apps\\PowerAccent.Common.dll",
"WinUI3Apps\\PowerToys.PowerAccent.dll",
"WinUI3Apps\\PowerToys.PowerAccent.exe",
"PowerAccent.Core.dll",
"PowerToys.PowerAccent.dll",
"PowerToys.PowerAccent.exe",
"PowerToys.PowerAccentModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerAccentKeyboardService.dll",
"PowerToys.PowerDisplayModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.exe",
"PowerDisplay.Lib.dll",
"PowerDisplay.Models.dll",
"PowerToys.PowerAccentKeyboardService.dll",
"WinUI3Apps\\PowerToys.PowerRenameExt.dll",
"WinUI3Apps\\PowerToys.PowerRename.exe",
@@ -244,20 +230,13 @@
"WinUI3Apps\\PowerToys.RegistryPreview.dll",
"WinUI3Apps\\PowerToys.RegistryPreview.exe",
"WinUI3Apps\\PowerToys.ShortcutGuide.exe",
"WinUI3Apps\\PowerToys.ShortcutGuide.dll",
"WinUI3Apps\\PowerToys.ShortcutGuideModuleInterface.dll",
"WinUI3Apps\\PowerToys.ShortcutGuide.IndexYmlGenerator.dll",
"WinUI3Apps\\PowerToys.ShortcutGuide.IndexYmlGenerator.exe",
"WinUI3Apps\\ShortcutGuide.CPPProject.dll",
"PowerToys.ShortcutGuide.exe",
"PowerToys.ShortcutGuideModuleInterface.dll",
"PowerToys.ZoomIt.exe",
"PowerToys.ZoomItModuleInterface.dll",
"PowerToys.ZoomItSettingsInterop.dll",
"PowerToys.GrabAndMove.exe",
"PowerToys.GrabAndMoveModuleInterface.dll",
"WinUI3Apps\\PowerToys.Settings.dll",
"WinUI3Apps\\PowerToys.Settings.exe",
@@ -269,8 +248,8 @@
"Workspaces.ModuleServices.dll",
"Microsoft.CommandPalette.Extensions.dll",
"Microsoft.CommandPalette.Extensions.Toolkit.dll",
"WinUI3Apps\\Microsoft.CmdPal.Ext.PowerToys.dll",
"WinUI3Apps\\Microsoft.CmdPal.Ext.PowerToys.exe",
"Microsoft.CmdPal.Ext.PowerToys.dll",
"Microsoft.CmdPal.Ext.PowerToys.exe",
"*Microsoft.CmdPal.UI_*.msix",
"PowerToys.DSC.dll",
@@ -388,11 +367,6 @@
"ColorCode.Core.dll",
"Microsoft.SemanticKernel.Connectors.Ollama.dll",
"OllamaSharp.dll",
"WinUI3Apps\\Google.Apis.dll",
"WinUI3Apps\\Google.Apis.Auth.dll",
"WinUI3Apps\\Google.Apis.Core.dll",
"WinUI3Apps\\Google.GenAI.dll",
"WinUI3Apps\\YamlDotNet.dll",
"boost_regex-vc143-mt-gd-x32-1_87.dll",
"boost_regex-vc143-mt-gd-x64-1_87.dll",
@@ -404,8 +378,6 @@
"UnitsNet.dll",
"UtfUnknown.dll",
"Wpf.Ui.dll",
"WmiLight.dll",
"WmiLight.Native.dll",
"Shmuelie.WinRTServer.dll",
"ToolGood.Words.Pinyin.dll"
],

View File

@@ -1,71 +0,0 @@
[CmdletBinding()]
Param(
# Target architecture: 'x64' or 'arm64'. Defaults to the pipeline's BuildPlatform variable.
[string]$Platform = $env:BuildPlatform
)
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Stop'
# Pinned to the winappcli version the UITestAutomation.Next harness is validated against. Using
# the standalone CLI zip (rather than the MSIX / winget) keeps this working on agents that lack
# the App Installer and avoids MSIX registration entirely.
$Version = 'v0.3.2'
$NormalizedPlatform = if ([string]::IsNullOrWhiteSpace($Platform)) { 'x64' } else { $Platform.ToLowerInvariant() }
switch ($NormalizedPlatform)
{
'arm64'
{
$Asset = 'winappcli-arm64.zip'
$ExpectedHash = 'dfe9d6eb70618665e4adcee989be8ecd076bfd387714a35a5b38597196fed093'
}
default
{
$Asset = 'winappcli-x64.zip'
$ExpectedHash = '231373a4605ce7749172a70534ebab9305f91116e7f68d25cc73051372a6c579'
}
}
$DownloadUrl = "https://github.com/microsoft/winappCli/releases/download/$Version/$Asset"
$ZipPath = Join-Path $env:Temp $Asset
$InstallDir = Join-Path $env:Temp 'winappcli'
Write-Host "Downloading winappcli $Version ($Asset) from $DownloadUrl"
Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath
# Verify the download against the published SHA256 before trusting it.
$Hash = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash
if ($Hash -ne $ExpectedHash)
{
throw "$Asset has unexpected SHA256 hash: $Hash (expected $ExpectedHash)"
}
# Fresh extract each run so a stale copy can't shadow the pinned version.
if (Test-Path $InstallDir)
{
Remove-Item $InstallDir -Recurse -Force
}
Expand-Archive -Path $ZipPath -DestinationPath $InstallDir -Force
# Clear Mark-of-the-Web in case the agent applied it, so the CLI runs non-interactively.
Get-ChildItem -Path $InstallDir -Recurse | Unblock-File -ErrorAction SilentlyContinue
$winapp = Get-ChildItem -Path $InstallDir -Recurse -Filter 'winapp.exe' | Select-Object -First 1 -ExpandProperty FullName
if (-not $winapp)
{
throw "winapp.exe was not found after extracting $Asset to $InstallDir."
}
Write-Host "winappcli installed at: $winapp"
# The harness (WinappCli.TryResolveExecutable) checks WINAPP_CLI_PATH first; also prepend the
# folder to PATH so any other consumer in later steps resolves winapp.exe too.
Write-Host "##vso[task.setvariable variable=WINAPP_CLI_PATH]$winapp"
Write-Host "##vso[task.prependpath]$(Split-Path -Parent $winapp)"
& $winapp --version
if ($LASTEXITCODE -ne 0)
{
throw "winapp.exe failed to run ('--version' exited with $LASTEXITCODE)."
}

View File

@@ -13,36 +13,9 @@ Param(
# Root folder Path for processing
[Parameter(Mandatory=$False,Position=4)]
[string]$sourceLink = "https://microsoft.pkgs.visualstudio.com/ProjectReunion/_packaging/Project.Reunion.nuget.internal/nuget/v3/index.json",
# Use Azure Pipeline artifact as source for metapackage
[Parameter(Mandatory=$False,Position=5)]
[boolean]$useArtifactSource = $False,
# Azure DevOps organization URL
[Parameter(Mandatory=$False,Position=6)]
[string]$azureDevOpsOrg = "https://dev.azure.com/microsoft",
# Azure DevOps project name
[Parameter(Mandatory=$False,Position=7)]
[string]$azureDevOpsProject = "ProjectReunion",
# Pipeline build ID (or "latest" for latest build)
[Parameter(Mandatory=$False,Position=8)]
[string]$buildId = "",
# Artifact name containing the NuGet packages
[Parameter(Mandatory=$False,Position=9)]
[string]$artifactName = "WindowsAppSDK_Nuget_And_MSIX",
# Metapackage name to look for in artifact
[Parameter(Mandatory=$False,Position=10)]
[string]$metaPackageName = "Microsoft.WindowsAppSDK"
[string]$sourceLink = "https://microsoft.pkgs.visualstudio.com/ProjectReunion/_packaging/Project.Reunion.nuget.internal/nuget/v3/index.json"
)
# Script-level constants
$script:PackageVersionRegex = '^(.+?)\.(\d+\..*)$'
function Read-FileWithEncoding {
@@ -84,7 +57,7 @@ function Add-NuGetSourceAndMapping {
# Ensure packageSources exists
if (-not $Xml.configuration.packageSources) {
$null = $Xml.configuration.AppendChild($Xml.CreateElement("packageSources"))
$Xml.configuration.AppendChild($Xml.CreateElement("packageSources")) | Out-Null
}
$sources = $Xml.configuration.packageSources
@@ -93,13 +66,13 @@ function Add-NuGetSourceAndMapping {
if (-not $sourceNode) {
$sourceNode = $Xml.CreateElement("add")
$sourceNode.SetAttribute("key", $Key)
$null = $sources.AppendChild($sourceNode)
$sources.AppendChild($sourceNode) | Out-Null
}
$sourceNode.SetAttribute("value", $Value)
# Ensure packageSourceMapping exists
if (-not $Xml.configuration.packageSourceMapping) {
$null = $Xml.configuration.AppendChild($Xml.CreateElement("packageSourceMapping"))
$Xml.configuration.AppendChild($Xml.CreateElement("packageSourceMapping")) | Out-Null
}
$mapping = $Xml.configuration.packageSourceMapping
@@ -107,7 +80,7 @@ function Add-NuGetSourceAndMapping {
$invalidNodes = $mapping.SelectNodes("packageSource[not(@key) or @key='']")
if ($invalidNodes) {
foreach ($node in $invalidNodes) {
$null = $mapping.RemoveChild($node)
$mapping.RemoveChild($node) | Out-Null
}
}
@@ -118,9 +91,9 @@ function Add-NuGetSourceAndMapping {
$mappingSource.SetAttribute("key", $Key)
# Insert at top for priority
if ($mapping.HasChildNodes) {
$null = $mapping.InsertBefore($mappingSource, $mapping.FirstChild)
$mapping.InsertBefore($mappingSource, $mapping.FirstChild) | Out-Null
} else {
$null = $mapping.AppendChild($mappingSource)
$mapping.AppendChild($mappingSource) | Out-Null
}
}
@@ -137,273 +110,14 @@ function Add-NuGetSourceAndMapping {
foreach ($pattern in $Patterns) {
$pkg = $Xml.CreateElement("package")
$pkg.SetAttribute("pattern", $pattern)
$null = $mappingSource.AppendChild($pkg)
$mappingSource.AppendChild($pkg) | Out-Null
}
}
function Download-ArtifactFromPipeline {
param (
[string]$Organization,
[string]$Project,
[string]$BuildId,
[string]$ArtifactName,
[string]$OutputDir
)
Write-Host "Downloading artifact '$ArtifactName' from build $BuildId..."
$null = New-Item -ItemType Directory -Path $OutputDir -Force
try {
# Authenticate with Azure DevOps using System Access Token (if available)
if ($env:SYSTEM_ACCESSTOKEN) {
Write-Host "Authenticating with Azure DevOps using System Access Token..."
$env:AZURE_DEVOPS_EXT_PAT = $env:SYSTEM_ACCESSTOKEN
} else {
Write-Host "No SYSTEM_ACCESSTOKEN found, assuming az CLI is already authenticated..."
}
# Use az CLI to download artifact
& az pipelines runs artifact download `
--organization $Organization `
--project $Project `
--run-id $BuildId `
--artifact-name $ArtifactName `
--path $OutputDir
if ($LASTEXITCODE -eq 0) {
Write-Host "Successfully downloaded artifact to $OutputDir"
return $true
} else {
Write-Warning "Failed to download artifact. Exit code: $LASTEXITCODE"
return $false
}
} catch {
Write-Warning "Error downloading artifact: $_"
return $false
}
}
function Get-NuspecDependencies {
param (
[string]$NupkgPath,
[string]$TargetFramework = ""
)
$tempDir = Join-Path $env:TEMP "nuspec_parse_$(Get-Random)"
try {
# Extract .nupkg (it's a zip file)
# Workaround: Expand-Archive may not recognize .nupkg extension, so copy to .zip first
$tempZip = Join-Path $env:TEMP "temp_$(Get-Random).zip"
Copy-Item $NupkgPath -Destination $tempZip -Force
Expand-Archive -Path $tempZip -DestinationPath $tempDir -Force
Remove-Item $tempZip -Force -ErrorAction SilentlyContinue
# Find .nuspec file
$nuspecFile = Get-ChildItem -Path $tempDir -Filter "*.nuspec" -Recurse | Select-Object -First 1
if (-not $nuspecFile) {
Write-Warning "No .nuspec file found in $NupkgPath"
return @{}
}
[xml]$nuspec = Get-Content $nuspecFile.FullName
# Extract package info
$packageId = $nuspec.package.metadata.id
$version = $nuspec.package.metadata.version
Write-Host "Parsing $packageId version $version"
# Parse dependencies
$dependencies = @{}
$depGroups = $nuspec.package.metadata.dependencies.group
if ($depGroups) {
# Dependencies are grouped by target framework
foreach ($group in $depGroups) {
$fx = $group.targetFramework
Write-Host " Target Framework: $fx"
foreach ($dep in $group.dependency) {
$depId = $dep.id
$depVer = $dep.version
# Remove version range brackets if present (e.g., "[2.0.0]" -> "2.0.0")
$depVer = $depVer -replace '[\[\]]', ''
$dependencies[$depId] = $depVer
Write-Host " - ${depId} : ${depVer}"
}
}
} else {
# No grouping, direct dependencies
$deps = $nuspec.package.metadata.dependencies.dependency
if ($deps) {
foreach ($dep in $deps) {
$depId = $dep.id
$depVer = $dep.version
$depVer = $depVer -replace '[\[\]]', ''
$dependencies[$depId] = $depVer
Write-Host " - ${depId} : ${depVer}"
}
}
}
return $dependencies
}
catch {
Write-Warning "Failed to parse nuspec: $_"
return @{}
}
finally {
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Resolve-ArtifactBasedDependencies {
param (
[string]$ArtifactDir,
[string]$MetaPackageName,
[string]$SourceUrl,
[string]$OutputDir
)
Write-Host "Resolving dependencies from artifact-based metapackage..."
$null = New-Item -ItemType Directory -Path $OutputDir -Force
# Find the metapackage in artifact
$metaNupkg = Get-ChildItem -Path $ArtifactDir -Recurse -Filter "$MetaPackageName.*.nupkg" |
Where-Object { $_.Name -notmatch "Runtime" } |
Select-Object -First 1
if (-not $metaNupkg) {
Write-Warning "Metapackage $MetaPackageName not found in artifact"
return @{}
}
# Extract version from filename
if ($metaNupkg.Name -match "$MetaPackageName\.(.+)\.nupkg") {
$metaVersion = $Matches[1]
Write-Host "Found metapackage: $MetaPackageName version $metaVersion"
} else {
Write-Warning "Could not extract version from $($metaNupkg.Name)"
return @{}
}
# Parse dependencies from metapackage
$dependencies = Get-NuspecDependencies -NupkgPath $metaNupkg.FullName
# Copy metapackage to output directory
Copy-Item $metaNupkg.FullName -Destination $OutputDir -Force
Write-Host "Copied metapackage to $OutputDir"
# Prepare package versions hashtable - initialize with metapackage version
$packageVersions = @{ $MetaPackageName = $metaVersion }
# Copy Runtime package from artifact (it's not in feed) and extract its version
$runtimeNupkg = Get-ChildItem -Path $ArtifactDir -Recurse -Filter "$MetaPackageName.Runtime.*.nupkg" | Select-Object -First 1
if ($runtimeNupkg) {
Copy-Item $runtimeNupkg.FullName -Destination $OutputDir -Force
Write-Host "Copied Runtime package to $OutputDir"
# Extract version from Runtime package filename
if ($runtimeNupkg.Name -match "$MetaPackageName\.Runtime\.(.+)\.nupkg") {
$runtimeVersion = $Matches[1]
$packageVersions["$MetaPackageName.Runtime"] = $runtimeVersion
Write-Host "Extracted Runtime package version: $runtimeVersion"
} else {
Write-Warning "Could not extract version from Runtime package: $($runtimeNupkg.Name)"
}
}
# Download other dependencies from feed (excluding Runtime as it's already copied)
# Create temp nuget.config that includes both local packages and remote feed
# This allows NuGet to find packages already copied from artifact
$tempConfig = Join-Path $env:TEMP "nuget_artifact_$(Get-Random).config"
$tempConfigContent = @"
<?xml version='1.0' encoding='utf-8'?>
<configuration>
<packageSources>
<clear />
<add key='LocalPackages' value='$OutputDir' />
<add key='RemoteFeed' value='$SourceUrl' />
</packageSources>
</configuration>
"@
Set-Content -Path $tempConfig -Value $tempConfigContent
try {
foreach ($depId in $dependencies.Keys) {
# Skip Runtime as it's already copied from artifact
if ($depId -like "*Runtime*") {
# Don't overwrite the version we extracted from the Runtime package filename
if (-not $packageVersions.ContainsKey($depId)) {
$packageVersions[$depId] = $dependencies[$depId]
}
Write-Host "Skipping $depId (already in artifact)"
continue
}
$depVersion = $dependencies[$depId]
Write-Host "Downloading dependency: $depId version $depVersion from feed..."
& nuget install $depId `
-Version $depVersion `
-ConfigFile $tempConfig `
-OutputDirectory $OutputDir `
-NonInteractive `
-NoCache `
| Out-Null
if ($LASTEXITCODE -eq 0) {
$packageVersions[$depId] = $depVersion
Write-Host " Successfully downloaded $depId"
} else {
Write-Warning " Failed to download $depId version $depVersion"
}
}
}
finally {
Remove-Item $tempConfig -Force -ErrorAction SilentlyContinue
}
# Parse all downloaded packages to get actual versions
$directories = Get-ChildItem -Path $OutputDir -Directory
$allLocalPackages = @()
# Add metapackage and runtime to the list (they are .nupkg files, not directories)
$allLocalPackages += $MetaPackageName
if ($packageVersions.ContainsKey("$MetaPackageName.Runtime")) {
$allLocalPackages += "$MetaPackageName.Runtime"
}
foreach ($dir in $directories) {
if ($dir.Name -match $script:PackageVersionRegex) {
$pkgId = $Matches[1]
$pkgVer = $Matches[2]
$allLocalPackages += $pkgId
if (-not $packageVersions.ContainsKey($pkgId)) {
$packageVersions[$pkgId] = $pkgVer
}
}
}
# Update nuget.config dynamically during pipeline execution
# This modification is temporary and won't be committed back to the repo
$nugetConfig = Join-Path $rootPath "nuget.config"
$configData = Read-FileWithEncoding -Path $nugetConfig
[xml]$xml = $configData.Content
Add-NuGetSourceAndMapping -Xml $xml -Key "localpackages" -Value $OutputDir -Patterns $allLocalPackages
$xml.Save($nugetConfig)
Write-Host "Updated nuget.config with localpackages mapping (temporary, for pipeline execution only)."
return ,$packageVersions
}
function Resolve-WinAppSdkSplitDependencies {
Write-Host "Version $WinAppSDKVersion detected. Resolving split dependencies..."
$installDir = Join-Path $rootPath "localpackages\output"
$null = New-Item -ItemType Directory -Path $installDir -Force
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
# Create a temporary nuget.config to avoid interference from the repo's config
$tempConfig = Join-Path $env:TEMP "nuget_$(Get-Random).config"
@@ -417,24 +131,14 @@ function Resolve-WinAppSdkSplitDependencies {
if ($propsContent -match '<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="([^"]+)"') {
$buildToolsVersion = $Matches[1]
Write-Host "Downloading Microsoft.Windows.SDK.BuildTools version $buildToolsVersion..."
& nuget install Microsoft.Windows.SDK.BuildTools `
-Version $buildToolsVersion `
-ConfigFile $tempConfig `
-OutputDirectory $installDir `
-NonInteractive `
-NoCache `
| Out-Null
$nugetArgsBuildTools = "install Microsoft.Windows.SDK.BuildTools -Version $buildToolsVersion -ConfigFile $tempConfig -OutputDirectory $installDir -NonInteractive -NoCache"
Invoke-Expression "nuget $nugetArgsBuildTools" | Out-Null
}
}
# Download package to inspect nuspec and keep it for the build
& nuget install Microsoft.WindowsAppSDK `
-Version $WinAppSDKVersion `
-ConfigFile $tempConfig `
-OutputDirectory $installDir `
-NonInteractive `
-NoCache `
| Out-Null
$nugetArgs = "install Microsoft.WindowsAppSDK -Version $WinAppSDKVersion -ConfigFile $tempConfig -OutputDirectory $installDir -NonInteractive -NoCache"
Invoke-Expression "nuget $nugetArgs" | Out-Null
# Parse dependencies from the installed folders
# Folder structure is typically {PackageId}.{Version}
@@ -468,101 +172,52 @@ function Resolve-WinAppSdkSplitDependencies {
}
}
# Main logic: choose between artifact-based or feed-based approach
if ($useArtifactSource) {
Write-Host "=== Using Artifact-Based Source ===" -ForegroundColor Cyan
Write-Host "Organization: $azureDevOpsOrg"
Write-Host "Project: $azureDevOpsProject"
Write-Host "Build ID: $buildId"
Write-Host "Artifact: $artifactName"
if ([string]::IsNullOrEmpty($buildId) -or $buildId -eq 'N/A') {
Write-Error "buildId parameter is required when using artifact source. Please provide a valid Windows App SDK Build ID."
Write-Host "Tip: You can find the build ID from the Windows App SDK pipeline run in Azure DevOps."
exit 1
}
# Download artifact
$artifactDir = Join-Path $rootPath "localpackages\artifact"
$downloadSuccess = Download-ArtifactFromPipeline `
-Organization $azureDevOpsOrg `
-Project $azureDevOpsProject `
-BuildId $buildId `
-ArtifactName $artifactName `
-OutputDir $artifactDir
if (-not $downloadSuccess) {
Write-Host "Failed to download artifact"
exit 1
}
# Resolve dependencies from artifact
$installDir = Join-Path $rootPath "localpackages\output"
$packageVersions = Resolve-ArtifactBasedDependencies `
-ArtifactDir $artifactDir `
-MetaPackageName $metaPackageName `
-SourceUrl $sourceLink `
-OutputDir $installDir
if ($packageVersions.Count -eq 0) {
Write-Error "Failed to resolve dependencies from artifact"
exit 1
}
$WinAppSDKVersion = $packageVersions[$metaPackageName]
Write-Host "WinAppSDK Version: $WinAppSDKVersion"
Write-Host "##vso[task.setvariable variable=WinAppSDKVersion]$WinAppSDKVersion"
# Execute nuget list and capture the output
if ($useExperimentalVersion) {
# The nuget list for experimental versions will cost more time
# So, we will not use -AllVersions to wast time
# But it can only get the latest experimental version
Write-Host "Fetching WindowsAppSDK with experimental versions"
$nugetOutput = nuget list Microsoft.WindowsAppSDK `
-Source $sourceLink `
-Prerelease
# Filter versions based on the specified version prefix
$escapedVersionNumber = [regex]::Escape($winAppSdkVersionNumber)
$filteredVersions = $nugetOutput | Where-Object { $_ -match "Microsoft.WindowsAppSDK $escapedVersionNumber\." }
$latestVersions = $filteredVersions
} else {
Write-Host "=== Using Feed-Based Source ===" -ForegroundColor Cyan
# Execute nuget list and capture the output
if ($useExperimentalVersion) {
# The nuget list for experimental versions will cost more time
# So, we will not use -AllVersions to wast time
# But it can only get the latest experimental version
Write-Host "Fetching WindowsAppSDK with experimental versions"
$nugetOutput = nuget list Microsoft.WindowsAppSDK `
-Source $sourceLink `
-Prerelease
# Filter versions based on the specified version prefix
$escapedVersionNumber = [regex]::Escape($winAppSdkVersionNumber)
$filteredVersions = $nugetOutput | Where-Object { $_ -match "Microsoft.WindowsAppSDK $escapedVersionNumber\." }
$latestVersions = $filteredVersions
} else {
Write-Host "Fetching stable WindowsAppSDK versions for $winAppSdkVersionNumber"
$nugetOutput = nuget list Microsoft.WindowsAppSDK `
-Source $sourceLink `
-AllVersions
# Filter versions based on the specified version prefix
$escapedVersionNumber = [regex]::Escape($winAppSdkVersionNumber)
$filteredVersions = $nugetOutput | Where-Object { $_ -match "Microsoft.WindowsAppSDK $escapedVersionNumber\." }
$latestVersions = $filteredVersions | Sort-Object { [version]($_ -split ' ')[1] } -Descending | Select-Object -First 1
}
Write-Host "Latest versions found: $latestVersions"
# Extract the latest version number from the output
$latestVersion = $latestVersions -split "`n" | `
Select-String -Pattern 'Microsoft.WindowsAppSDK\s*([0-9]+\.[0-9]+\.[0-9]+-*[a-zA-Z0-9]*)' | `
ForEach-Object { $_.Matches[0].Groups[1].Value } | `
Sort-Object -Descending | `
Select-Object -First 1
if ($latestVersion) {
$WinAppSDKVersion = $latestVersion
Write-Host "Extracted version: $WinAppSDKVersion"
Write-Host "##vso[task.setvariable variable=WinAppSDKVersion]$WinAppSDKVersion"
} else {
Write-Host "Failed to extract version number from nuget list output"
exit 1
}
# Resolve dependencies for 1.8+
$packageVersions = @{ "Microsoft.WindowsAppSDK" = $WinAppSDKVersion }
Resolve-WinAppSdkSplitDependencies
Write-Host "Fetching stable WindowsAppSDK versions for $winAppSdkVersionNumber"
$nugetOutput = nuget list Microsoft.WindowsAppSDK `
-Source $sourceLink `
-AllVersions
# Filter versions based on the specified version prefix
$escapedVersionNumber = [regex]::Escape($winAppSdkVersionNumber)
$filteredVersions = $nugetOutput | Where-Object { $_ -match "Microsoft.WindowsAppSDK $escapedVersionNumber\." }
$latestVersions = $filteredVersions | Sort-Object { [version]($_ -split ' ')[1] } -Descending | Select-Object -First 1
}
Write-Host "Latest versions found: $latestVersions"
# Extract the latest version number from the output
$latestVersion = $latestVersions -split "`n" | `
Select-String -Pattern 'Microsoft.WindowsAppSDK\s*([0-9]+\.[0-9]+\.[0-9]+-*[a-zA-Z0-9]*)' | `
ForEach-Object { $_.Matches[0].Groups[1].Value } | `
Sort-Object -Descending | `
Select-Object -First 1
if ($latestVersion) {
$WinAppSDKVersion = $latestVersion
Write-Host "Extracted version: $WinAppSDKVersion"
Write-Host "##vso[task.setvariable variable=WinAppSDKVersion]$WinAppSDKVersion"
} else {
Write-Host "Failed to extract version number from nuget list output"
exit 1
}
# Resolve dependencies for 1.8+
$packageVersions = @{ "Microsoft.WindowsAppSDK" = $WinAppSDKVersion }
Resolve-WinAppSdkSplitDependencies
# Update Directory.Packages.props file
Get-ChildItem -Path $rootPath -Recurse "Directory.Packages.props" | ForEach-Object {
$file = Read-FileWithEncoding -Path $_.FullName
@@ -571,16 +226,9 @@ Get-ChildItem -Path $rootPath -Recurse "Directory.Packages.props" | ForEach-Obje
foreach ($pkgId in $packageVersions.Keys) {
$ver = $packageVersions[$pkgId]
# Skip packages with empty versions to prevent corruption
if ([string]::IsNullOrWhiteSpace($ver)) {
Write-Warning "Skipping ${pkgId}: version is empty"
continue
}
# Escape dots in package ID for regex
$pkgIdRegex = $pkgId -replace '\.', '\.'
$newVersionString = "<PackageVersion Include=""$pkgId"" Version=""$ver"" />"
$oldVersionString = "<PackageVersion Include=""$pkgIdRegex"" Version=""[-.0-9a-zA-Z]*"" />"

View File

@@ -9,7 +9,7 @@ schedules:
always: false # only run if there's code changes!
pool:
vmImage: windows-latest
vmImage: windows-2019
resources:
repositories:

View File

@@ -1,8 +1,3 @@
# NOTE: When using artifact mode (useArtifactSource: true), the pipeline needs
# permission to access System.AccessToken. This is automatically handled by the
# script if SYSTEM_ACCESSTOKEN environment variable is available.
# If you encounter authentication errors, ensure the job has oauth access enabled.
trigger: none
pr: none
schedules:
@@ -42,23 +37,6 @@ parameters:
- name: useExperimentalVersion
type: boolean
default: false
# Artifact mode parameters (optional)
- name: useArtifactSource
type: boolean
displayName: "Use Artifact Source (instead of feed)"
default: false
- name: buildId
type: string
displayName: "Windows App SDK Build ID (required only if using artifact source)"
default: 'N/A'
- name: azureDevOpsProject
type: string
displayName: "Source Project (for artifact mode, default: ProjectReunion)"
default: 'ProjectReunion'
- name: artifactName
type: string
displayName: "Artifact Name (for artifact mode, default: WindowsAppSDK_Nuget_And_MSIX)"
default: 'WindowsAppSDK_Nuget_And_MSIX'
extends:
template: templates/pipeline-ci-build.yml
@@ -71,7 +49,3 @@ extends:
useLatestWinAppSDK: ${{ parameters.useLatestWinAppSDK }}
winAppSDKVersionNumber: ${{ parameters.winAppSDKVersionNumber }}
useExperimentalVersion: ${{ parameters.useExperimentalVersion }}
useArtifactSource: ${{ parameters.useArtifactSource }}
buildId: ${{ parameters.buildId }}
azureDevOpsProject: ${{ parameters.azureDevOpsProject }}
artifactName: ${{ parameters.artifactName }}

View File

@@ -16,7 +16,6 @@ pr:
include:
- main
- stable
drafts: false
# paths:
# exclude:
# - '**.md'

View File

@@ -104,10 +104,6 @@ extends:
# Have msbuild use the release nuget config profile
additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" /p:EnableCmdPalAOT=true
beforeBuildSteps:
# Install the Terrapin retrieval tool, which replaces vcpkg's download handler
# to redirect it to a safe Microsoft-controlled location
- template: .pipelines/v2/templates/steps-install-terrapin.yml@self
# Sets versions for all PowerToy created DLLs
- pwsh: |-
.pipelines/versionSetting.ps1 -versionNumber '${{ parameters.versionNumber }}' -DevEnvironment ''
@@ -144,10 +140,6 @@ extends:
signCertName: $(SigningSignCertName)
useManagedIdentity: $(SigningUseManagedIdentity)
clientId: $(SigningOriginalClientId)
beforeBuildSteps:
# Install the Terrapin retrieval tool, which replaces vcpkg's download handler
# to redirect it to a safe Microsoft-controlled location
- template: .pipelines/v2/templates/steps-install-terrapin.yml@self
- stage: Publish
displayName: Publish

View File

@@ -70,29 +70,10 @@ parameters:
default: false
- name: winAppSDKVersionNumber
type: string
default: '2.0'
default: 1.6
- name: useExperimentalVersion
type: boolean
default: false
# Artifact mode parameters
- name: useArtifactSource
type: boolean
default: false
- name: azureDevOpsOrg
type: string
default: 'https://dev.azure.com/microsoft'
- name: azureDevOpsProject
type: string
default: 'ProjectReunion'
- name: buildId
type: string
default: ''
- name: artifactName
type: string
default: 'WindowsAppSDK_Nuget_And_MSIX'
- name: metaPackageName
type: string
default: 'Microsoft.WindowsAppSDK'
- name: csProjectsToPublish
type: object
default:
@@ -171,11 +152,6 @@ jobs:
fetchTags: false
fetchDepth: 1
# Checkout to surface a missing import before full build.
- pwsh: |-
& '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src'
displayName: Audit shared common props for CSharp projects in src sub-folder
- ${{ if eq(parameters.enableMsBuildCaching, true) }}:
- pwsh: |-
$MSBuildCacheParameters = ""
@@ -205,7 +181,7 @@ jobs:
- template: steps-ensure-dotnet-version.yml
parameters:
sdk: true
version: '10.0'
version: '9.0'
- ${{ if eq(parameters.runTests, true) }}:
- task: VisualStudioTestPlatformInstaller@1
@@ -215,9 +191,6 @@ jobs:
& '.pipelines/applyXamlStyling.ps1' -Passive
displayName: Verify XAML formatting
- task: NuGetAuthenticate@1
displayName: Authenticate NuGet feeds for verification
- pwsh: |-
& '.pipelines/verifyNugetPackages.ps1' -solution '$(build.sourcesdirectory)\PowerToys.slnx'
displayName: Verify Nuget package versions for PowerToys.slnx
@@ -253,12 +226,6 @@ jobs:
parameters:
versionNumber: ${{ parameters.winAppSDKVersionNumber }}
useExperimentalVersion: ${{ parameters.useExperimentalVersion }}
useArtifactSource: ${{ parameters.useArtifactSource }}
azureDevOpsOrg: ${{ parameters.azureDevOpsOrg }}
azureDevOpsProject: ${{ parameters.azureDevOpsProject }}
buildId: ${{ parameters.buildId }}
artifactName: ${{ parameters.artifactName }}
metaPackageName: ${{ parameters.metaPackageName }}
- ${{ if eq(parameters.useLatestWinAppSDK, false)}}:
- template: .\steps-restore-nuget.yml
@@ -275,34 +242,6 @@ jobs:
parameters:
directory: $(build.sourcesdirectory)\src\modules\cmdpal
# --- vcpkg detection + binary cache --------------------------------------
# PowerToys consumes spdlog (and, over time, other native deps) via vcpkg in
# manifest mode. steps-install-vcpkg.yml prefers the vcpkg shipped with
# Visual Studio (Microsoft.VisualStudio.Component.Vcpkg) and falls back to a
# fresh clone of microsoft/vcpkg into deps/vcpkg if VS doesn't have it.
# Either way it sets the VCPKG_ROOT pipeline variable; MSBuild integration
# is wired globally from Cpp.Build.props (with vcpkg.targets in
# Cpp.Build.targets) using the three-tier VcpkgRoot fallback
# (env var > VS-shipped > deps/vcpkg runtime clone).
#
# Vcpkg's MSBuild integration runs `vcpkg install` once per project, so the
# binary cache below saves ~3-5 minutes per triplet on cache hits.
- template: .\steps-install-vcpkg.yml
parameters:
useVSPreview: ${{ parameters.useVSPreview }}
- ${{ if eq(parameters.enablePackageCaching, true) }}:
- task: Cache@2
displayName: 'Cache vcpkg binary archives'
inputs:
# Key on the inputs vcpkg uses to compute its package ABI: the manifest,
# configuration, every overlay-port file, and the agent OS.
key: '"vcpkg" | "$(Agent.OS)" | vcpkg.json | vcpkg-configuration.json | deps/vcpkg-overlays/**'
restoreKeys: |
"vcpkg" | "$(Agent.OS)"
"vcpkg"
path: $(LOCALAPPDATA)\vcpkg\archives
- ${{ parameters.beforeBuildSteps }}
@@ -451,7 +390,7 @@ jobs:
/p:VCRTForwarders-IncludeDebugCRT=false
/p:PowerToysRoot=$(Build.SourcesDirectory)
/p:PublishProfile=InstallationPublishProfile.pubxml
/p:TargetFramework=net10.0-windows10.0.26100.0
/p:TargetFramework=net9.0-windows10.0.26100.0
/bl:$(LogOutputDirectory)\publish-${{ join('_',split(project, '/')) }}.binlog
$(RestoreAdditionalProjectSourcesArg)
platform: $(BuildPlatform)
@@ -469,6 +408,11 @@ jobs:
flattenFolders: True
OverWrite: True
# Check if all projects (located in src sub-folder) import common props
- pwsh: |-
& '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src'
displayName: Audit shared common props for CSharp projects in src sub-folder
# Check if deps.json files don't reference different dll versions.
- pwsh: |-
& '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\$(BuildPlatform)\$(BuildConfiguration)'

View File

@@ -15,9 +15,6 @@ parameters:
- name: signingIdentity
type: object
default: {}
- name: beforeBuildSteps
type: stepList
default: []
jobs:
- job: "BuildSDK"
@@ -48,8 +45,6 @@ jobs:
parameters:
directory: $(build.sourcesdirectory)\src\modules\cmdpal
- ${{ parameters.beforeBuildSteps }}
- pwsh: |-
& "$(build.sourcesdirectory)\src\modules\cmdpal\extensionsdk\nuget\BuildSDKHelper.ps1" -Configuration "Release" -BuildStep "build" -IsAzurePipelineBuild
displayName: Build SDK

View File

@@ -64,7 +64,7 @@ jobs:
- template: steps-ensure-dotnet-version.yml
parameters:
sdk: true
version: '10.0'
version: '9.0'
- template: .\steps-restore-nuget.yml

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