mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-30 16:07:29 +01:00
Compare commits
19 Commits
shawn/AddT
...
leilzh/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7acd177052 | ||
|
|
38c1aedc1b | ||
|
|
fca6d67a2e | ||
|
|
64cabc8789 | ||
|
|
989e005500 | ||
|
|
5f124cec55 | ||
|
|
8ec530c65e | ||
|
|
f82afdf384 | ||
|
|
aa2ba0c325 | ||
|
|
f534e5b8e5 | ||
|
|
08715a6e46 | ||
|
|
d26d9f745a | ||
|
|
6661adbd5c | ||
|
|
5ecb97b4e0 | ||
|
|
13ce5db6b1 | ||
|
|
f0831742d6 | ||
|
|
ea43974287 | ||
|
|
0b3dc089ac | ||
|
|
4ba6fd2723 |
@@ -565,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+
|
||||
regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\)
|
||||
|
||||
# regex choice
|
||||
\(\?:[^)]+\|[^)]+\)
|
||||
# \(\?:[^)]+\|[^)]+\)
|
||||
|
||||
# proto
|
||||
^\s*(\w+)\s\g{-1} =
|
||||
|
||||
2
.github/actions/spell-check/excludes.txt
vendored
2
.github/actions/spell-check/excludes.txt
vendored
@@ -106,6 +106,8 @@
|
||||
^src/common/sysinternals/Eula/
|
||||
^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\.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$
|
||||
|
||||
62
.github/actions/spell-check/expect.txt
vendored
62
.github/actions/spell-check/expect.txt
vendored
@@ -22,7 +22,6 @@ ADate
|
||||
ADDSTRING
|
||||
ADDUNDORECORD
|
||||
ADifferent
|
||||
adjacents
|
||||
ADMINS
|
||||
adml
|
||||
admx
|
||||
@@ -219,10 +218,11 @@ CIELCh
|
||||
cim
|
||||
CImage
|
||||
cla
|
||||
claude
|
||||
CLASSDC
|
||||
classguid
|
||||
classmethod
|
||||
CLASSNOTAVAILABLE
|
||||
claude
|
||||
CLEARTYPE
|
||||
clickable
|
||||
clickonce
|
||||
@@ -261,7 +261,6 @@ colorhistory
|
||||
colorhistorylimit
|
||||
COLORKEY
|
||||
colorref
|
||||
Convs
|
||||
comctl
|
||||
comdlg
|
||||
comexp
|
||||
@@ -282,6 +281,7 @@ CONTEXTHELP
|
||||
CONTEXTMENUHANDLER
|
||||
contractversion
|
||||
CONTROLPARENT
|
||||
Convs
|
||||
copiedcolorrepresentation
|
||||
coppied
|
||||
copyable
|
||||
@@ -348,12 +348,14 @@ datareader
|
||||
datatracker
|
||||
dataversion
|
||||
Dayof
|
||||
dbcc
|
||||
DBID
|
||||
DBLCLKS
|
||||
DBLEPSILON
|
||||
DBPROP
|
||||
DBPROPIDSET
|
||||
DBPROPSET
|
||||
DBT
|
||||
DCBA
|
||||
DCOM
|
||||
DComposition
|
||||
@@ -371,8 +373,7 @@ DEFAULTICON
|
||||
defaultlib
|
||||
DEFAULTONLY
|
||||
DEFAULTSIZE
|
||||
DEFAULTTONEAREST
|
||||
Defaulttonearest
|
||||
defaulttonearest
|
||||
DEFAULTTONULL
|
||||
DEFAULTTOPRIMARY
|
||||
DEFERERASE
|
||||
@@ -394,14 +395,19 @@ DESKTOPVERTRES
|
||||
devblogs
|
||||
devdocs
|
||||
devenv
|
||||
DEVICEINTERFACE
|
||||
devicetype
|
||||
DEVINTERFACE
|
||||
devmgmt
|
||||
DEVMODE
|
||||
DEVMODEW
|
||||
DEVNODES
|
||||
devpal
|
||||
DEVTYP
|
||||
dfx
|
||||
DIALOGEX
|
||||
digicert
|
||||
diffs
|
||||
digicert
|
||||
DINORMAL
|
||||
DISABLEASACTIONKEY
|
||||
DISABLENOSCROLL
|
||||
@@ -544,7 +550,6 @@ fdx
|
||||
FErase
|
||||
fesf
|
||||
FFFF
|
||||
FInc
|
||||
Figma
|
||||
FILEEXPLORER
|
||||
fileexploreraddons
|
||||
@@ -565,6 +570,7 @@ FILESYSPATH
|
||||
Filetime
|
||||
FILEVERSION
|
||||
FILTERMODE
|
||||
FInc
|
||||
findfast
|
||||
findmymouse
|
||||
FIXEDFILEINFO
|
||||
@@ -591,6 +597,7 @@ frm
|
||||
FROMTOUCH
|
||||
fsanitize
|
||||
fsmgmt
|
||||
ftps
|
||||
fuzzingtesting
|
||||
fxf
|
||||
FZE
|
||||
@@ -666,13 +673,14 @@ HCRYPTPROV
|
||||
hcursor
|
||||
hcwhite
|
||||
hdc
|
||||
HDEVNOTIFY
|
||||
hdr
|
||||
hdrop
|
||||
hdwwiz
|
||||
Helpline
|
||||
helptext
|
||||
HGFE
|
||||
hgdiobj
|
||||
HGFE
|
||||
hglobal
|
||||
hhk
|
||||
HHmmssfff
|
||||
@@ -748,9 +756,9 @@ HWNDPARENT
|
||||
HWNDPREV
|
||||
hyjiacan
|
||||
IAI
|
||||
icf
|
||||
ICONERROR
|
||||
ICONLOCATION
|
||||
icf
|
||||
IDCANCEL
|
||||
IDD
|
||||
idk
|
||||
@@ -841,8 +849,8 @@ jeli
|
||||
jfif
|
||||
jgeosdfsdsgmkedfgdfgdfgbkmhcgcflmi
|
||||
jjw
|
||||
JOBOBJECT
|
||||
jobject
|
||||
JOBOBJECT
|
||||
jpe
|
||||
jpnime
|
||||
Jsons
|
||||
@@ -929,9 +937,9 @@ LOWORD
|
||||
lparam
|
||||
LPBITMAPINFOHEADER
|
||||
LPCFHOOKPROC
|
||||
lpch
|
||||
LPCITEMIDLIST
|
||||
LPCLSID
|
||||
lpch
|
||||
lpcmi
|
||||
LPCMINVOKECOMMANDINFO
|
||||
LPCREATESTRUCT
|
||||
@@ -947,6 +955,7 @@ LPMONITORINFO
|
||||
LPOSVERSIONINFOEXW
|
||||
LPQUERY
|
||||
lprc
|
||||
LPrivate
|
||||
LPSAFEARRAY
|
||||
lpstr
|
||||
lpsz
|
||||
@@ -956,7 +965,6 @@ lptpm
|
||||
LPTR
|
||||
LPTSTR
|
||||
lpv
|
||||
LPrivate
|
||||
LPW
|
||||
lpwcx
|
||||
lpwndpl
|
||||
@@ -1000,19 +1008,18 @@ mber
|
||||
MBM
|
||||
MBR
|
||||
Mbuttondown
|
||||
mcp
|
||||
MDICHILD
|
||||
MDL
|
||||
mdtext
|
||||
mdtxt
|
||||
mdwn
|
||||
meme
|
||||
mcp
|
||||
memicmp
|
||||
MENUITEMINFO
|
||||
MENUITEMINFOW
|
||||
MERGECOPY
|
||||
MERGEPAINT
|
||||
Metacharacter
|
||||
metadatamatters
|
||||
Metadatas
|
||||
metafile
|
||||
@@ -1042,8 +1049,8 @@ mmi
|
||||
mmsys
|
||||
mobileredirect
|
||||
mockapi
|
||||
modelcontextprotocol
|
||||
MODALFRAME
|
||||
modelcontextprotocol
|
||||
MODESPRUNED
|
||||
MONITORENUMPROC
|
||||
MONITORINFO
|
||||
@@ -1087,9 +1094,9 @@ MSLLHOOKSTRUCT
|
||||
Mso
|
||||
msrc
|
||||
msstore
|
||||
mstsc
|
||||
msvcp
|
||||
MT
|
||||
mstsc
|
||||
MTND
|
||||
MULTIPLEUSE
|
||||
multizone
|
||||
@@ -1099,11 +1106,11 @@ muxxc
|
||||
muxxh
|
||||
MVPs
|
||||
mvvm
|
||||
myorg
|
||||
myrepo
|
||||
MVVMTK
|
||||
MWBEx
|
||||
MYICON
|
||||
myorg
|
||||
myrepo
|
||||
NAMECHANGE
|
||||
namespaceanddescendants
|
||||
nao
|
||||
@@ -1244,10 +1251,8 @@ opencode
|
||||
OPENFILENAME
|
||||
openrdp
|
||||
opensource
|
||||
openxmlformats
|
||||
ollama
|
||||
onnx
|
||||
openurl
|
||||
openxmlformats
|
||||
OPTIMIZEFORINVOKE
|
||||
ORPHANEDDIALOGTITLE
|
||||
ORSCANS
|
||||
@@ -1325,7 +1330,7 @@ phwnd
|
||||
pici
|
||||
pidl
|
||||
PIDLIST
|
||||
PII
|
||||
pii
|
||||
pinfo
|
||||
pinvoke
|
||||
pipename
|
||||
@@ -1464,7 +1469,6 @@ rbhid
|
||||
Rbuttondown
|
||||
rclsid
|
||||
RCZOOMIT
|
||||
remotedesktop
|
||||
rdp
|
||||
RDW
|
||||
READMODE
|
||||
@@ -1493,6 +1497,7 @@ remappings
|
||||
REMAPSUCCESSFUL
|
||||
REMAPUNSUCCESSFUL
|
||||
Remotable
|
||||
remotedesktop
|
||||
remoteip
|
||||
Removelnk
|
||||
renamable
|
||||
@@ -1526,8 +1531,8 @@ RIGHTSCROLLBAR
|
||||
riid
|
||||
RKey
|
||||
RNumber
|
||||
rop
|
||||
rollups
|
||||
rop
|
||||
ROUNDSMALL
|
||||
ROWSETEXT
|
||||
rpcrt
|
||||
@@ -1711,6 +1716,7 @@ srw
|
||||
srwlock
|
||||
sse
|
||||
ssf
|
||||
Ssn
|
||||
sszzz
|
||||
STACKFRAME
|
||||
stackoverflow
|
||||
@@ -1766,8 +1772,7 @@ SVGIO
|
||||
svgz
|
||||
SVSI
|
||||
SWFO
|
||||
SWP
|
||||
Swp
|
||||
swp
|
||||
SWPNOSIZE
|
||||
SWPNOZORDER
|
||||
SWRESTORE
|
||||
@@ -1786,8 +1791,7 @@ SYSKEY
|
||||
syskeydown
|
||||
SYSKEYUP
|
||||
SYSLIB
|
||||
SYSMENU
|
||||
Sysmenu
|
||||
sysmenu
|
||||
systemai
|
||||
SYSTEMAPPS
|
||||
SYSTEMMODAL
|
||||
@@ -1891,9 +1895,9 @@ uitests
|
||||
UITo
|
||||
ULONGLONG
|
||||
Ultrawide
|
||||
ums
|
||||
UMax
|
||||
UMin
|
||||
ums
|
||||
uncompilable
|
||||
UNCPRIORITY
|
||||
UNDNAME
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Generate an 80-character git commit title for the local diff'
|
||||
---
|
||||
|
||||
|
||||
1
.github/prompts/create-pr-summary.prompt.md
vendored
1
.github/prompts/create-pr-summary.prompt.md
vendored
@@ -1,6 +1,5 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Generate a PowerToys-ready pull request description from the local diff'
|
||||
---
|
||||
|
||||
|
||||
1
.github/prompts/fix-issue.prompt.md
vendored
1
.github/prompts/fix-issue.prompt.md
vendored
@@ -1,6 +1,5 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Execute the fix for a GitHub issue using the previously generated implementation plan'
|
||||
---
|
||||
|
||||
|
||||
70
.github/prompts/fix-pr-active-comments.prompt.md
vendored
Normal file
70
.github/prompts/fix-pr-active-comments.prompt.md
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
description: 'Fix active pull request comments with scoped changes'
|
||||
name: 'fix-pr-active-comments'
|
||||
agent: 'agent'
|
||||
argument-hint: 'PR number or active PR URL'
|
||||
---
|
||||
|
||||
# Fix Active PR Comments
|
||||
|
||||
## Mission
|
||||
Resolve active pull request comments by applying only simple fixes. For complex refactors, write a plan instead of changing code.
|
||||
|
||||
## Scope & Preconditions
|
||||
- You must have an active pull request context or a provided PR number.
|
||||
- Only implement simple changes. Do not implement large refactors.
|
||||
- If required context is missing, request it and stop.
|
||||
|
||||
## Inputs
|
||||
- Required: ${input:pr_number:PR number or URL}
|
||||
- Optional: ${input:comment_scope:files or areas to focus on}
|
||||
- Optional: ${input:fixing_guidelines:additional fixing guidelines from the user}
|
||||
|
||||
## Workflow
|
||||
1. Locate all active (unresolved) PR review comments for the given PR.
|
||||
2. For each comment, classify the change scope:
|
||||
- Simple change: limited edits, localized fix, low risk, no broad redesign.
|
||||
- Large refactor: multi-file redesign, architecture change, or risky behavior change.
|
||||
3. For each large refactor request:
|
||||
- Do not modify code.
|
||||
- Write a planning document to Generated Files/prReview/${input:pr_number}/fixPlan/.
|
||||
4. For each simple change request:
|
||||
- Implement the fix with minimal edits.
|
||||
- Run quick checks if needed.
|
||||
- Commit and push the change.
|
||||
5. For comments that seem invalid, unclear, or not applicable (even if simple):
|
||||
- Do not change code.
|
||||
- Add the item to a summary table in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md.
|
||||
- Consult back to the end user in a friendly, polite tone.
|
||||
6. Respond to each comment that you fixed:
|
||||
- Reply in the active conversation.
|
||||
- Use a polite or friendly tone.
|
||||
- Keep the response under 200 words.
|
||||
- Resolve the comment after replying.
|
||||
|
||||
## Output Expectations
|
||||
- Simple fixes: code changes committed and pushed.
|
||||
- Large refactors: a plan file saved to Generated Files/prReview/${input:pr_number}/fixPlan/.
|
||||
- Invalid or unclear comments: captured in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md.
|
||||
- Each fixed comment has a reply under 200 words and is resolved.
|
||||
|
||||
## Plan File Template
|
||||
Use this template for each large refactor item:
|
||||
|
||||
# Fix Plan: <short title>
|
||||
|
||||
## Context
|
||||
- Comment link:
|
||||
- Impacted areas:
|
||||
|
||||
## Overview Table Template
|
||||
Use this table in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md:
|
||||
|
||||
| Comment link | Summary | Reason not applied | Suggested follow-up |
|
||||
| --- | --- | --- | --- |
|
||||
| | | | |
|
||||
|
||||
## Quality Assurance
|
||||
- Verify plan file path exists.
|
||||
- Ensure no code changes were made for large refactor items.
|
||||
- Confirm replies are under 200 words and comments are resolved.
|
||||
1
.github/prompts/fix-spelling.prompt.md
vendored
1
.github/prompts/fix-spelling.prompt.md
vendored
@@ -1,6 +1,5 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Resolve Code scanning / check-spelling comments on the active PR'
|
||||
---
|
||||
|
||||
|
||||
1
.github/prompts/review-issue.prompt.md
vendored
1
.github/prompts/review-issue.prompt.md
vendored
@@ -1,6 +1,5 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Review a GitHub issue, score it (0-100), and generate an implementation plan'
|
||||
---
|
||||
|
||||
|
||||
1
.github/prompts/review-pr.prompt.md
vendored
1
.github/prompts/review-pr.prompt.md
vendored
@@ -1,6 +1,5 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Perform a comprehensive PR review with per-step Markdown and machine-readable outputs'
|
||||
---
|
||||
|
||||
|
||||
@@ -300,6 +300,10 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/CommandPalette/Tests/">
|
||||
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
|
||||
62
README.md
62
README.md
@@ -51,19 +51,19 @@ But to get started quickly, choose one of the installation methods below:
|
||||
Go to the <a href="https://aka.ms/installPowerToys">PowerToys GitHub releases</a>, click Assets to reveal the downloads, and choose the installer that matches your architecture and install scope. For most devices, that's the x64 per-user installer.
|
||||
|
||||
<!-- items that need to be updated release to release -->
|
||||
[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22
|
||||
[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.96%22
|
||||
[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.0/PowerToysUserSetup-0.97.0-x64.exe
|
||||
[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.0/PowerToysUserSetup-0.97.0-arm64.exe
|
||||
[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.0/PowerToysSetup-0.97.0-x64.exe
|
||||
[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.0/PowerToysSetup-0.97.0-arm64.exe
|
||||
[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.98%22
|
||||
[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22
|
||||
[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysUserSetup-0.97.1-x64.exe
|
||||
[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysUserSetup-0.97.1-arm64.exe
|
||||
[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysSetup-0.97.1-x64.exe
|
||||
[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysSetup-0.97.1-arm64.exe
|
||||
|
||||
| Description | Filename |
|
||||
|----------------|----------|
|
||||
| Per user - x64 | [PowerToysUserSetup-0.97.0-x64.exe][ptUserX64] |
|
||||
| Per user - ARM64 | [PowerToysUserSetup-0.97.0-arm64.exe][ptUserArm64] |
|
||||
| Machine wide - x64 | [PowerToysSetup-0.97.0-x64.exe][ptMachineX64] |
|
||||
| Machine wide - ARM64 | [PowerToysSetup-0.97.0-arm64.exe][ptMachineArm64] |
|
||||
| Per user - x64 | [PowerToysUserSetup-0.97.1-x64.exe][ptUserX64] |
|
||||
| Per user - ARM64 | [PowerToysUserSetup-0.97.1-arm64.exe][ptUserArm64] |
|
||||
| Machine wide - x64 | [PowerToysSetup-0.97.1-x64.exe][ptMachineX64] |
|
||||
| Machine wide - ARM64 | [PowerToysSetup-0.97.1-arm64.exe][ptMachineArm64] |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -103,18 +103,38 @@ There are <a href="https://learn.microsoft.com/windows/powertoys/install#communi
|
||||
</details>
|
||||
|
||||
## ✨ What's new
|
||||
**Version 0.97 (January 2026)**
|
||||
**Version 0.97.1 (January 2026)**
|
||||
|
||||
For an in-depth look at the latest changes, visit the [Windows Command Line blog](https://aka.ms/powertoys-releaseblog).
|
||||
This patch release fixes several important stability issues identified in v0.97.0 based on incoming reports. Check out the [v0.97.0](https://github.com/microsoft/PowerToys/releases/tag/v0.97.0) notes for the full list of changes.
|
||||
|
||||
**✨ Highlights**
|
||||
- **Command Palette**: Major expansion with PowerToys extension (Windows 11 only), Remote Desktop built-in extension, theme customization, drag-and-drop support, fallback ranking controls, sections/separators for pages, pinyin Chinese matching, and many UX refinements.
|
||||
- **Settings**: Quick Access flyout is now a standalone process for significantly faster startup, theme-adaptive tray icon, AOT serialization, and multiple UI/accessibility fixes
|
||||
- **CursorWrap (New!)**: New mouse utility that lets your cursor wrap around screen edges, making multi-monitor navigation faster and more seamless.
|
||||
- **Advanced Paste**: Image input for AI, color detection in clipboard history, Foundry Local improvements, Azure AI icons, and multiple bug fixes
|
||||
- **CLI Support Expanded**: FancyZones, Image Resizer, and File Locksmith can now be controlled from the command line for layout management, batch image resizing, and file lock inspection.
|
||||
- **LightSwitch**: Added support for automatically following Windows Night Light mode.
|
||||
- **Release Experience & Quality**: Refreshed "What’s new" dialog, plus many performance improvements, stability fixes, and refinements across PowerToys.
|
||||
**Highlights**
|
||||
|
||||
### Advanced Paste
|
||||
- #44862: Fixed Settings UI advanced paste page crash by using correct settings repository for null checking.
|
||||
|
||||
### Command Palette
|
||||
- #44886: Fixed personalization section not appearing by using latest MSIX for installation.
|
||||
- #44938: Fixed loading of icons from internet shortcuts. Thanks [@jiripolasek](https://github.com/jiripolasek)!
|
||||
- #45076: Fixed potential deadlock from lazy-loading AppListItem details. Thanks [@jiripolasek](https://github.com/jiripolasek)!
|
||||
|
||||
### Cursor Wrap
|
||||
- #44936: Added improved multi-monitor support; Added laptop lid close detection for dynamic monitor topology updates. Thanks [@mikehall-ms](https://github.com/mikehall-ms)!
|
||||
- #44936: Added new settings dropdown to constrain wrapping to horizontal-only, vertical-only, or both directions. Thanks [@mikehall-ms](https://github.com/mikehall-ms)!
|
||||
|
||||
### Peek
|
||||
- #44995: Fixed Space key triggering Peek during file rename, search, or address bar typing.
|
||||
|
||||
### PowerRename
|
||||
- #44944: Fixed regex `$` not working, preventing users from adding text at the end of filenames.
|
||||
|
||||
### Runner
|
||||
- #44931: Monochrome tray icon now adapts to Windows system theme instead of app theme.
|
||||
- #44982: Fixed right-click menu to dynamically update based on Quick Access enabled/disabled state.
|
||||
|
||||
### GPO / Enterprise
|
||||
- #45028: Added CursorWrap policy definition to ADMX templates. Thanks [@htcfreek](https://github.com/htcfreek)!
|
||||
|
||||
For the full list of v0.97 changes, visit the [Windows Command Line blog](https://aka.ms/powertoys-releaseblog).
|
||||
|
||||
## Advanced Paste
|
||||
|
||||
@@ -289,7 +309,7 @@ For an in-depth look at the latest changes, visit the [Windows Command Line blog
|
||||
- Stabilized FancyZones UI tests with more reliable selectors and screen recordings.
|
||||
|
||||
## 🛣️ Roadmap
|
||||
We are planning some nice new features and improvements for the next releases – PowerDisplay, Command Palette improvements and a brand-new Shortcut Guide experience! Stay tuned for [v0.97][github-next-release-work]!
|
||||
We are planning some nice new features and improvements for the next releases – PowerDisplay, Command Palette improvements and a brand-new Shortcut Guide experience! Stay tuned for [v0.98][github-next-release-work]!
|
||||
|
||||
## ❤️ PowerToys Community
|
||||
The PowerToys team is extremely grateful to have the [support of an amazing active community][community-link]. The work you do is incredibly important. PowerToys wouldn't be nearly what it is today without your help filing bugs, updating documentation, guiding the design, or writing features. We want to say thank you and take time to recognize your work. Your contributions and feedback improve PowerToys month after month!
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<Custom Action="UnRegisterCmdPalPackage" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallCommandNotFound" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UpgradeCommandNotFound" After="InstallFiles" Condition="WIX_UPGRADE_DETECTED" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallServicesTask" After="InstallFinalize" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<!-- TODO: Use to activate embedded MSIX -->
|
||||
<!--<Custom Action="UninstallEmbeddedMSIXTask" After="InstallFinalize">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) Microsoft Corporation.
|
||||
Licensed under the MIT License. -->
|
||||
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.18" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
|
||||
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.19" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
|
||||
<policyNamespaces>
|
||||
<target prefix="powertoys" namespace="Microsoft.Policies.PowerToys" />
|
||||
</policyNamespaces>
|
||||
<resources minRequiredRevision="1.18"/><!-- Last changed with PowerToys v0.96.0 -->
|
||||
<resources minRequiredRevision="1.19"/><!-- Last changed with PowerToys v0.97.0 -->
|
||||
<supportedOn>
|
||||
<definitions>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_64_0" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0)"/>
|
||||
@@ -27,6 +27,7 @@
|
||||
<definition name="SUPPORTED_POWERTOYS_0_89_0" displayName="$(string.SUPPORTED_POWERTOYS_0_89_0)"/>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_90_0" displayName="$(string.SUPPORTED_POWERTOYS_0_90_0)"/>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_96_0" displayName="$(string.SUPPORTED_POWERTOYS_0_96_0)"/>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_97_0" displayName="$(string.SUPPORTED_POWERTOYS_0_97_0)"/>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1)"/>
|
||||
</definitions>
|
||||
</supportedOn>
|
||||
@@ -338,6 +339,16 @@
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="ConfigureEnabledUtilityCursorWrap" class="Both" displayName="$(string.ConfigureEnabledUtilityCursorWrap)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityCursorWrap">
|
||||
<parentCategory ref="PowerToys" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_97_0" />
|
||||
<enabledValue>
|
||||
<decimal value="1" />
|
||||
</enabledValue>
|
||||
<disabledValue>
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="ConfigureEnabledUtilityFindMyMouse" class="Both" displayName="$(string.ConfigureEnabledUtilityFindMyMouse)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityFindMyMouse">
|
||||
<parentCategory ref="PowerToys" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_64_0" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) Microsoft Corporation.
|
||||
Licensed under the MIT License. -->
|
||||
<policyDefinitionResources xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.18" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
|
||||
<policyDefinitionResources xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.19" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
|
||||
<displayName>PowerToys</displayName>
|
||||
<description>PowerToys</description>
|
||||
<resources>
|
||||
@@ -34,6 +34,7 @@
|
||||
<string id="SUPPORTED_POWERTOYS_0_89_0">PowerToys version 0.89.0 or later</string>
|
||||
<string id="SUPPORTED_POWERTOYS_0_90_0">PowerToys version 0.90.0 or later</string>
|
||||
<string id="SUPPORTED_POWERTOYS_0_96_0">PowerToys version 0.96.0 or later</string>
|
||||
<string id="SUPPORTED_POWERTOYS_0_97_0">PowerToys version 0.97.0 or later</string>
|
||||
<string id="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1">From PowerToys version 0.64.0 until PowerToys version 0.87.1</string>
|
||||
|
||||
<string id="ConfigureAllUtilityGlobalEnabledStateDescription">This policy configures the enabled state for all PowerToys utilities.
|
||||
@@ -266,6 +267,7 @@ If you don't configure this policy, the user will be able to control the setting
|
||||
<string id="ConfigureEnabledUtilityKeyboardManager">Keyboard Manager: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityFindMyMouse">Find My Mouse: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityMouseHighlighter">Mouse Highlighter: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityCursorWrap">CursorWrap: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityMouseJump">Mouse Jump: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityMousePointerCrosshairs">Mouse Pointer Crosshairs: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityMouseWithoutBorders">Mouse Without Borders: Configure enabled state</string>
|
||||
|
||||
@@ -84,14 +84,17 @@
|
||||
</ClCompile>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="CursorWrapCore.h" />
|
||||
<ClInclude Include="CursorWrapTests.h" />
|
||||
<ClInclude Include="MonitorTopology.h" />
|
||||
<ClInclude Include="pch.h" />
|
||||
<ClInclude Include="trace.h" />
|
||||
<ClInclude Include="resource.h" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="CursorWrapCore.cpp" />
|
||||
<ClCompile Include="dllmain.cpp" />
|
||||
|
||||
<ClCompile Include="MonitorTopology.cpp" />
|
||||
<ClCompile Include="pch.cpp">
|
||||
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
|
||||
268
src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp
Normal file
268
src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#include "pch.h"
|
||||
#include "CursorWrapCore.h"
|
||||
#include "../../../common/logger/logger.h"
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <ctime>
|
||||
|
||||
CursorWrapCore::CursorWrapCore()
|
||||
{
|
||||
}
|
||||
|
||||
#ifdef _DEBUG
|
||||
std::wstring CursorWrapCore::GenerateTopologyJSON() const
|
||||
{
|
||||
std::wostringstream json;
|
||||
|
||||
// Get current time
|
||||
auto now = std::time(nullptr);
|
||||
std::tm tm{};
|
||||
localtime_s(&tm, &now);
|
||||
|
||||
wchar_t computerName[MAX_COMPUTERNAME_LENGTH + 1] = {0};
|
||||
DWORD size = MAX_COMPUTERNAME_LENGTH + 1;
|
||||
GetComputerNameW(computerName, &size);
|
||||
|
||||
wchar_t userName[256] = {0};
|
||||
size = 256;
|
||||
GetUserNameW(userName, &size);
|
||||
|
||||
json << L"{\n";
|
||||
json << L" \"captured_at\": \"" << std::put_time(&tm, L"%Y-%m-%dT%H:%M:%S%z") << L"\",\n";
|
||||
json << L" \"computer_name\": \"" << computerName << L"\",\n";
|
||||
json << L" \"user_name\": \"" << userName << L"\",\n";
|
||||
json << L" \"monitor_count\": " << m_monitors.size() << L",\n";
|
||||
json << L" \"monitors\": [\n";
|
||||
|
||||
for (size_t i = 0; i < m_monitors.size(); ++i)
|
||||
{
|
||||
const auto& monitor = m_monitors[i];
|
||||
|
||||
// Get DPI for this monitor
|
||||
UINT dpiX = 96, dpiY = 96;
|
||||
POINT center = {
|
||||
(monitor.rect.left + monitor.rect.right) / 2,
|
||||
(monitor.rect.top + monitor.rect.bottom) / 2
|
||||
};
|
||||
HMONITOR hMon = MonitorFromPoint(center, MONITOR_DEFAULTTONEAREST);
|
||||
if (hMon)
|
||||
{
|
||||
// Try GetDpiForMonitor (requires linking Shcore.lib)
|
||||
using GetDpiForMonitorFunc = HRESULT (WINAPI *)(HMONITOR, int, UINT*, UINT*);
|
||||
HMODULE shcore = LoadLibraryW(L"Shcore.dll");
|
||||
if (shcore)
|
||||
{
|
||||
auto getDpi = reinterpret_cast<GetDpiForMonitorFunc>(GetProcAddress(shcore, "GetDpiForMonitor"));
|
||||
if (getDpi)
|
||||
{
|
||||
getDpi(hMon, 0, &dpiX, &dpiY); // MDT_EFFECTIVE_DPI = 0
|
||||
}
|
||||
FreeLibrary(shcore);
|
||||
}
|
||||
}
|
||||
|
||||
int scalingPercent = static_cast<int>((dpiX / 96.0) * 100);
|
||||
|
||||
json << L" {\n";
|
||||
json << L" \"left\": " << monitor.rect.left << L",\n";
|
||||
json << L" \"top\": " << monitor.rect.top << L",\n";
|
||||
json << L" \"right\": " << monitor.rect.right << L",\n";
|
||||
json << L" \"bottom\": " << monitor.rect.bottom << L",\n";
|
||||
json << L" \"width\": " << (monitor.rect.right - monitor.rect.left) << L",\n";
|
||||
json << L" \"height\": " << (monitor.rect.bottom - monitor.rect.top) << L",\n";
|
||||
json << L" \"dpi\": " << dpiX << L",\n";
|
||||
json << L" \"scaling_percent\": " << scalingPercent << L",\n";
|
||||
json << L" \"primary\": " << (monitor.isPrimary ? L"true" : L"false") << L",\n";
|
||||
json << L" \"monitor_id\": " << monitor.monitorId << L"\n";
|
||||
json << L" }";
|
||||
if (i < m_monitors.size() - 1)
|
||||
{
|
||||
json << L",";
|
||||
}
|
||||
json << L"\n";
|
||||
}
|
||||
|
||||
json << L" ]\n";
|
||||
json << L"}";
|
||||
|
||||
return json.str();
|
||||
}
|
||||
#endif
|
||||
|
||||
void CursorWrapCore::UpdateMonitorInfo()
|
||||
{
|
||||
size_t previousMonitorCount = m_monitors.size();
|
||||
Logger::info(L"======= UPDATE MONITOR INFO START =======");
|
||||
Logger::info(L"Previous monitor count: {}", previousMonitorCount);
|
||||
|
||||
m_monitors.clear();
|
||||
|
||||
EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) -> BOOL {
|
||||
auto* self = reinterpret_cast<CursorWrapCore*>(lParam);
|
||||
|
||||
MONITORINFO mi{};
|
||||
mi.cbSize = sizeof(MONITORINFO);
|
||||
if (GetMonitorInfo(hMonitor, &mi))
|
||||
{
|
||||
MonitorInfo info{};
|
||||
info.hMonitor = hMonitor; // Store handle for direct comparison later
|
||||
info.rect = mi.rcMonitor;
|
||||
info.isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0;
|
||||
info.monitorId = static_cast<int>(self->m_monitors.size());
|
||||
self->m_monitors.push_back(info);
|
||||
|
||||
Logger::info(L"Enumerated monitor {}: hMonitor={}, rect=({},{},{},{}), primary={}",
|
||||
info.monitorId, reinterpret_cast<uintptr_t>(hMonitor),
|
||||
mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom,
|
||||
info.isPrimary ? L"yes" : L"no");
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}, reinterpret_cast<LPARAM>(this));
|
||||
|
||||
if (previousMonitorCount != m_monitors.size())
|
||||
{
|
||||
Logger::info(L"*** MONITOR CONFIGURATION CHANGED: {} -> {} monitors ***",
|
||||
previousMonitorCount, m_monitors.size());
|
||||
}
|
||||
|
||||
m_topology.Initialize(m_monitors);
|
||||
|
||||
// Log monitor configuration summary
|
||||
Logger::info(L"Monitor configuration updated: {} monitor(s)", m_monitors.size());
|
||||
for (size_t i = 0; i < m_monitors.size(); ++i)
|
||||
{
|
||||
const auto& m = m_monitors[i];
|
||||
int width = m.rect.right - m.rect.left;
|
||||
int height = m.rect.bottom - m.rect.top;
|
||||
Logger::info(L" Monitor {}: {}x{} at ({}, {}){}",
|
||||
i, width, height, m.rect.left, m.rect.top,
|
||||
m.isPrimary ? L" [PRIMARY]" : L"");
|
||||
}
|
||||
Logger::info(L" Detected {} outer edges for cursor wrapping", m_topology.GetOuterEdges().size());
|
||||
|
||||
// Detect and log monitor gaps
|
||||
auto gaps = m_topology.DetectMonitorGaps();
|
||||
if (!gaps.empty())
|
||||
{
|
||||
Logger::warn(L"Monitor configuration has coordinate gaps that may prevent wrapping:");
|
||||
for (const auto& gap : gaps)
|
||||
{
|
||||
Logger::warn(L" Gap between Monitor {} and Monitor {}: {}px horizontal gap, {}px vertical overlap",
|
||||
gap.monitor1Index, gap.monitor2Index, gap.horizontalGap, gap.verticalOverlap);
|
||||
}
|
||||
Logger::warn(L" If monitors appear snapped in Display Settings but show gaps here:");
|
||||
Logger::warn(L" 1. Try dragging monitors apart and snapping them back together");
|
||||
Logger::warn(L" 2. Update your GPU drivers");
|
||||
}
|
||||
|
||||
Logger::info(L"======= UPDATE MONITOR INFO END =======");
|
||||
}
|
||||
|
||||
POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode)
|
||||
{
|
||||
// Check if wrapping should be disabled during drag
|
||||
if (disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000))
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
OutputDebugStringW(L"[CursorWrap] [DRAG] Left mouse button down - skipping wrap\n");
|
||||
#endif
|
||||
return currentPos;
|
||||
}
|
||||
|
||||
// Convert int wrapMode to WrapMode enum
|
||||
WrapMode mode = static_cast<WrapMode>(wrapMode);
|
||||
|
||||
#ifdef _DEBUG
|
||||
{
|
||||
std::wostringstream oss;
|
||||
oss << L"[CursorWrap] [MOVE] Cursor at (" << currentPos.x << L", " << currentPos.y << L")";
|
||||
|
||||
// Get current monitor and identify which one
|
||||
HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST);
|
||||
RECT monitorRect;
|
||||
if (m_topology.GetMonitorRect(currentMonitor, monitorRect))
|
||||
{
|
||||
// Find monitor ID
|
||||
int monitorId = -1;
|
||||
for (const auto& monitor : m_monitors)
|
||||
{
|
||||
if (monitor.rect.left == monitorRect.left &&
|
||||
monitor.rect.top == monitorRect.top &&
|
||||
monitor.rect.right == monitorRect.right &&
|
||||
monitor.rect.bottom == monitorRect.bottom)
|
||||
{
|
||||
monitorId = monitor.monitorId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
oss << L" on Monitor " << monitorId << L" [" << monitorRect.left << L".." << monitorRect.right
|
||||
<< L", " << monitorRect.top << L".." << monitorRect.bottom << L"]";
|
||||
}
|
||||
else
|
||||
{
|
||||
oss << L" (beyond monitor bounds)";
|
||||
}
|
||||
oss << L"\n";
|
||||
OutputDebugStringW(oss.str().c_str());
|
||||
}
|
||||
#endif
|
||||
|
||||
// Get current monitor
|
||||
HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST);
|
||||
|
||||
// Check if cursor is on an outer edge (filtered by wrap mode)
|
||||
EdgeType edgeType;
|
||||
if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode))
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
static bool lastWasNotOuter = false;
|
||||
if (!lastWasNotOuter)
|
||||
{
|
||||
OutputDebugStringW(L"[CursorWrap] [MOVE] Not on outer edge - no wrapping\n");
|
||||
lastWasNotOuter = true;
|
||||
}
|
||||
#endif
|
||||
return currentPos; // Not on an outer edge
|
||||
}
|
||||
|
||||
#ifdef _DEBUG
|
||||
{
|
||||
const wchar_t* edgeStr = L"Unknown";
|
||||
switch (edgeType)
|
||||
{
|
||||
case EdgeType::Left: edgeStr = L"Left"; break;
|
||||
case EdgeType::Right: edgeStr = L"Right"; break;
|
||||
case EdgeType::Top: edgeStr = L"Top"; break;
|
||||
case EdgeType::Bottom: edgeStr = L"Bottom"; break;
|
||||
}
|
||||
std::wostringstream oss;
|
||||
oss << L"[CursorWrap] [EDGE] Detected outer " << edgeStr << L" edge at (" << currentPos.x << L", " << currentPos.y << L")\n";
|
||||
OutputDebugStringW(oss.str().c_str());
|
||||
}
|
||||
#endif
|
||||
|
||||
// Calculate wrap destination
|
||||
POINT newPos = m_topology.GetWrapDestination(currentMonitor, currentPos, edgeType);
|
||||
|
||||
#ifdef _DEBUG
|
||||
if (newPos.x != currentPos.x || newPos.y != currentPos.y)
|
||||
{
|
||||
std::wostringstream oss;
|
||||
oss << L"[CursorWrap] [WRAP] Position change: (" << currentPos.x << L", " << currentPos.y
|
||||
<< L") -> (" << newPos.x << L", " << newPos.y << L")\n";
|
||||
oss << L"[CursorWrap] [WRAP] Delta: (" << (newPos.x - currentPos.x) << L", " << (newPos.y - currentPos.y) << L")\n";
|
||||
OutputDebugStringW(oss.str().c_str());
|
||||
}
|
||||
else
|
||||
{
|
||||
OutputDebugStringW(L"[CursorWrap] [WRAP] No position change (same-monitor wrap?)\n");
|
||||
}
|
||||
#endif
|
||||
|
||||
return newPos;
|
||||
}
|
||||
33
src/modules/MouseUtils/CursorWrap/CursorWrapCore.h
Normal file
33
src/modules/MouseUtils/CursorWrap/CursorWrapCore.h
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include "MonitorTopology.h"
|
||||
|
||||
// Core cursor wrapping engine
|
||||
class CursorWrapCore
|
||||
{
|
||||
public:
|
||||
CursorWrapCore();
|
||||
|
||||
void UpdateMonitorInfo();
|
||||
|
||||
// Handle mouse move with wrap mode filtering
|
||||
// wrapMode: 0=Both, 1=VerticalOnly, 2=HorizontalOnly
|
||||
POINT HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode);
|
||||
|
||||
const std::vector<MonitorInfo>& GetMonitors() const { return m_monitors; }
|
||||
const MonitorTopology& GetTopology() const { return m_topology; }
|
||||
|
||||
private:
|
||||
#ifdef _DEBUG
|
||||
std::wstring GenerateTopologyJSON() const;
|
||||
#endif
|
||||
|
||||
std::vector<MonitorInfo> m_monitors;
|
||||
MonitorTopology m_topology;
|
||||
};
|
||||
546
src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp
Normal file
546
src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp
Normal file
@@ -0,0 +1,546 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#include "pch.h"
|
||||
#include "MonitorTopology.h"
|
||||
#include "../../../common/logger/logger.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
void MonitorTopology::Initialize(const std::vector<MonitorInfo>& monitors)
|
||||
{
|
||||
Logger::info(L"======= TOPOLOGY INITIALIZATION START =======");
|
||||
Logger::info(L"Initializing edge-based topology for {} monitors", monitors.size());
|
||||
|
||||
m_monitors = monitors;
|
||||
m_outerEdges.clear();
|
||||
m_edgeMap.clear();
|
||||
|
||||
if (monitors.empty())
|
||||
{
|
||||
Logger::warn(L"No monitors provided to Initialize");
|
||||
return;
|
||||
}
|
||||
|
||||
// Log monitor details
|
||||
for (size_t i = 0; i < monitors.size(); ++i)
|
||||
{
|
||||
const auto& m = monitors[i];
|
||||
Logger::info(L"Monitor {}: hMonitor={}, rect=({},{},{},{}), primary={}",
|
||||
i, reinterpret_cast<uintptr_t>(m.hMonitor),
|
||||
m.rect.left, m.rect.top, m.rect.right, m.rect.bottom,
|
||||
m.isPrimary ? L"yes" : L"no");
|
||||
}
|
||||
|
||||
BuildEdgeMap();
|
||||
IdentifyOuterEdges();
|
||||
|
||||
Logger::info(L"Found {} outer edges", m_outerEdges.size());
|
||||
for (const auto& edge : m_outerEdges)
|
||||
{
|
||||
const wchar_t* typeStr = L"Unknown";
|
||||
switch (edge.type)
|
||||
{
|
||||
case EdgeType::Left: typeStr = L"Left"; break;
|
||||
case EdgeType::Right: typeStr = L"Right"; break;
|
||||
case EdgeType::Top: typeStr = L"Top"; break;
|
||||
case EdgeType::Bottom: typeStr = L"Bottom"; break;
|
||||
}
|
||||
Logger::info(L"Outer edge: Monitor {} {} at position {}, range [{}, {}]",
|
||||
edge.monitorIndex, typeStr, edge.position, edge.start, edge.end);
|
||||
}
|
||||
Logger::info(L"======= TOPOLOGY INITIALIZATION COMPLETE =======");
|
||||
}
|
||||
|
||||
void MonitorTopology::BuildEdgeMap()
|
||||
{
|
||||
// Create edges for each monitor using monitor index (not HMONITOR)
|
||||
// This is important because HMONITOR handles can change when monitors are
|
||||
// added/removed dynamically, but indices remain stable within a single
|
||||
// topology configuration
|
||||
for (size_t idx = 0; idx < m_monitors.size(); ++idx)
|
||||
{
|
||||
const auto& monitor = m_monitors[idx];
|
||||
int monitorIndex = static_cast<int>(idx);
|
||||
|
||||
// Left edge
|
||||
MonitorEdge leftEdge;
|
||||
leftEdge.monitorIndex = monitorIndex;
|
||||
leftEdge.type = EdgeType::Left;
|
||||
leftEdge.position = monitor.rect.left;
|
||||
leftEdge.start = monitor.rect.top;
|
||||
leftEdge.end = monitor.rect.bottom;
|
||||
leftEdge.isOuter = true; // Will be updated in IdentifyOuterEdges
|
||||
m_edgeMap[{monitorIndex, EdgeType::Left}] = leftEdge;
|
||||
|
||||
// Right edge
|
||||
MonitorEdge rightEdge;
|
||||
rightEdge.monitorIndex = monitorIndex;
|
||||
rightEdge.type = EdgeType::Right;
|
||||
rightEdge.position = monitor.rect.right - 1;
|
||||
rightEdge.start = monitor.rect.top;
|
||||
rightEdge.end = monitor.rect.bottom;
|
||||
rightEdge.isOuter = true;
|
||||
m_edgeMap[{monitorIndex, EdgeType::Right}] = rightEdge;
|
||||
|
||||
// Top edge
|
||||
MonitorEdge topEdge;
|
||||
topEdge.monitorIndex = monitorIndex;
|
||||
topEdge.type = EdgeType::Top;
|
||||
topEdge.position = monitor.rect.top;
|
||||
topEdge.start = monitor.rect.left;
|
||||
topEdge.end = monitor.rect.right;
|
||||
topEdge.isOuter = true;
|
||||
m_edgeMap[{monitorIndex, EdgeType::Top}] = topEdge;
|
||||
|
||||
// Bottom edge
|
||||
MonitorEdge bottomEdge;
|
||||
bottomEdge.monitorIndex = monitorIndex;
|
||||
bottomEdge.type = EdgeType::Bottom;
|
||||
bottomEdge.position = monitor.rect.bottom - 1;
|
||||
bottomEdge.start = monitor.rect.left;
|
||||
bottomEdge.end = monitor.rect.right;
|
||||
bottomEdge.isOuter = true;
|
||||
m_edgeMap[{monitorIndex, EdgeType::Bottom}] = bottomEdge;
|
||||
}
|
||||
}
|
||||
|
||||
void MonitorTopology::IdentifyOuterEdges()
|
||||
{
|
||||
const int tolerance = 50;
|
||||
|
||||
// Check each edge against all other edges to find adjacent ones
|
||||
for (auto& [key1, edge1] : m_edgeMap)
|
||||
{
|
||||
for (const auto& [key2, edge2] : m_edgeMap)
|
||||
{
|
||||
if (edge1.monitorIndex == edge2.monitorIndex)
|
||||
{
|
||||
continue; // Same monitor
|
||||
}
|
||||
|
||||
// Check if edges are adjacent
|
||||
if (EdgesAreAdjacent(edge1, edge2, tolerance))
|
||||
{
|
||||
edge1.isOuter = false;
|
||||
break; // This edge has an adjacent monitor
|
||||
}
|
||||
}
|
||||
|
||||
if (edge1.isOuter)
|
||||
{
|
||||
m_outerEdges.push_back(edge1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool MonitorTopology::EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance) const
|
||||
{
|
||||
// Edges must be opposite types to be adjacent
|
||||
bool oppositeTypes = false;
|
||||
|
||||
if ((edge1.type == EdgeType::Left && edge2.type == EdgeType::Right) ||
|
||||
(edge1.type == EdgeType::Right && edge2.type == EdgeType::Left) ||
|
||||
(edge1.type == EdgeType::Top && edge2.type == EdgeType::Bottom) ||
|
||||
(edge1.type == EdgeType::Bottom && edge2.type == EdgeType::Top))
|
||||
{
|
||||
oppositeTypes = true;
|
||||
}
|
||||
|
||||
if (!oppositeTypes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if positions are within tolerance
|
||||
if (abs(edge1.position - edge2.position) > tolerance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if perpendicular ranges overlap
|
||||
int overlapStart = max(edge1.start, edge2.start);
|
||||
int overlapEnd = min(edge1.end, edge2.end);
|
||||
|
||||
return overlapEnd > overlapStart + tolerance;
|
||||
}
|
||||
|
||||
bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const
|
||||
{
|
||||
RECT monitorRect;
|
||||
if (!GetMonitorRect(monitor, monitorRect))
|
||||
{
|
||||
Logger::warn(L"IsOnOuterEdge: GetMonitorRect failed for monitor handle {}", reinterpret_cast<uintptr_t>(monitor));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get monitor index for edge map lookup
|
||||
int monitorIndex = GetMonitorIndex(monitor);
|
||||
if (monitorIndex < 0)
|
||||
{
|
||||
Logger::warn(L"IsOnOuterEdge: Monitor index not found for handle {} at cursor ({}, {})",
|
||||
reinterpret_cast<uintptr_t>(monitor), cursorPos.x, cursorPos.y);
|
||||
return false; // Monitor not found in our list
|
||||
}
|
||||
|
||||
// Check each edge type
|
||||
const int edgeThreshold = 1;
|
||||
|
||||
// At corners, multiple edges may match - collect all candidates and try each
|
||||
// to find one with a valid wrap destination
|
||||
std::vector<EdgeType> candidateEdges;
|
||||
|
||||
// Left edge - only if mode allows horizontal wrapping
|
||||
if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::HorizontalOnly) &&
|
||||
cursorPos.x <= monitorRect.left + edgeThreshold)
|
||||
{
|
||||
auto it = m_edgeMap.find({monitorIndex, EdgeType::Left});
|
||||
if (it != m_edgeMap.end() && it->second.isOuter)
|
||||
{
|
||||
candidateEdges.push_back(EdgeType::Left);
|
||||
}
|
||||
}
|
||||
|
||||
// Right edge - only if mode allows horizontal wrapping
|
||||
if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::HorizontalOnly) &&
|
||||
cursorPos.x >= monitorRect.right - 1 - edgeThreshold)
|
||||
{
|
||||
auto it = m_edgeMap.find({monitorIndex, EdgeType::Right});
|
||||
if (it != m_edgeMap.end())
|
||||
{
|
||||
if (it->second.isOuter)
|
||||
{
|
||||
candidateEdges.push_back(EdgeType::Right);
|
||||
}
|
||||
// Debug: Log why right edge isn't outer
|
||||
else
|
||||
{
|
||||
Logger::trace(L"IsOnOuterEdge: Monitor {} right edge is NOT outer (inner edge)", monitorIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top edge - only if mode allows vertical wrapping
|
||||
if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::VerticalOnly) &&
|
||||
cursorPos.y <= monitorRect.top + edgeThreshold)
|
||||
{
|
||||
auto it = m_edgeMap.find({monitorIndex, EdgeType::Top});
|
||||
if (it != m_edgeMap.end() && it->second.isOuter)
|
||||
{
|
||||
candidateEdges.push_back(EdgeType::Top);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom edge - only if mode allows vertical wrapping
|
||||
if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::VerticalOnly) &&
|
||||
cursorPos.y >= monitorRect.bottom - 1 - edgeThreshold)
|
||||
{
|
||||
auto it = m_edgeMap.find({monitorIndex, EdgeType::Bottom});
|
||||
if (it != m_edgeMap.end() && it->second.isOuter)
|
||||
{
|
||||
candidateEdges.push_back(EdgeType::Bottom);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateEdges.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try each candidate edge and return first with valid wrap destination
|
||||
for (EdgeType candidate : candidateEdges)
|
||||
{
|
||||
MonitorEdge oppositeEdge = FindOppositeOuterEdge(candidate,
|
||||
(candidate == EdgeType::Left || candidate == EdgeType::Right) ? cursorPos.y : cursorPos.x);
|
||||
|
||||
if (oppositeEdge.monitorIndex >= 0)
|
||||
{
|
||||
outEdgeType = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const
|
||||
{
|
||||
// Get monitor index for edge map lookup
|
||||
int monitorIndex = GetMonitorIndex(fromMonitor);
|
||||
if (monitorIndex < 0)
|
||||
{
|
||||
return cursorPos; // Monitor not found
|
||||
}
|
||||
|
||||
auto it = m_edgeMap.find({monitorIndex, edgeType});
|
||||
if (it == m_edgeMap.end())
|
||||
{
|
||||
return cursorPos; // Edge not found
|
||||
}
|
||||
|
||||
const MonitorEdge& fromEdge = it->second;
|
||||
|
||||
// Calculate relative position on current edge (0.0 to 1.0)
|
||||
double relativePos = GetRelativePosition(fromEdge,
|
||||
(edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x);
|
||||
|
||||
// Find opposite outer edge
|
||||
MonitorEdge oppositeEdge = FindOppositeOuterEdge(edgeType,
|
||||
(edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x);
|
||||
|
||||
if (oppositeEdge.monitorIndex < 0)
|
||||
{
|
||||
// No opposite edge found, wrap within same monitor
|
||||
RECT monitorRect;
|
||||
if (GetMonitorRect(fromMonitor, monitorRect))
|
||||
{
|
||||
POINT result = cursorPos;
|
||||
switch (edgeType)
|
||||
{
|
||||
case EdgeType::Left:
|
||||
result.x = monitorRect.right - 2;
|
||||
break;
|
||||
case EdgeType::Right:
|
||||
result.x = monitorRect.left + 1;
|
||||
break;
|
||||
case EdgeType::Top:
|
||||
result.y = monitorRect.bottom - 2;
|
||||
break;
|
||||
case EdgeType::Bottom:
|
||||
result.y = monitorRect.top + 1;
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return cursorPos;
|
||||
}
|
||||
|
||||
// Calculate target position on opposite edge
|
||||
POINT result;
|
||||
|
||||
if (edgeType == EdgeType::Left || edgeType == EdgeType::Right)
|
||||
{
|
||||
// Horizontal edge -> vertical movement
|
||||
result.x = oppositeEdge.position;
|
||||
result.y = GetAbsolutePosition(oppositeEdge, relativePos);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Vertical edge -> horizontal movement
|
||||
result.y = oppositeEdge.position;
|
||||
result.x = GetAbsolutePosition(oppositeEdge, relativePos);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
MonitorEdge MonitorTopology::FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const
|
||||
{
|
||||
EdgeType targetType;
|
||||
bool findMax; // true = find max position, false = find min position
|
||||
|
||||
switch (fromEdge)
|
||||
{
|
||||
case EdgeType::Left:
|
||||
targetType = EdgeType::Right;
|
||||
findMax = true;
|
||||
break;
|
||||
case EdgeType::Right:
|
||||
targetType = EdgeType::Left;
|
||||
findMax = false;
|
||||
break;
|
||||
case EdgeType::Top:
|
||||
targetType = EdgeType::Bottom;
|
||||
findMax = true;
|
||||
break;
|
||||
case EdgeType::Bottom:
|
||||
targetType = EdgeType::Top;
|
||||
findMax = false;
|
||||
break;
|
||||
default:
|
||||
return { .monitorIndex = -1 }; // Invalid edge type
|
||||
}
|
||||
|
||||
MonitorEdge result = { .monitorIndex = -1 }; // -1 indicates not found
|
||||
int extremePosition = findMax ? INT_MIN : INT_MAX;
|
||||
|
||||
for (const auto& edge : m_outerEdges)
|
||||
{
|
||||
if (edge.type != targetType)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this edge overlaps with the relative position
|
||||
if (relativePosition >= edge.start && relativePosition <= edge.end)
|
||||
{
|
||||
if ((findMax && edge.position > extremePosition) ||
|
||||
(!findMax && edge.position < extremePosition))
|
||||
{
|
||||
extremePosition = edge.position;
|
||||
result = edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
double MonitorTopology::GetRelativePosition(const MonitorEdge& edge, int coordinate) const
|
||||
{
|
||||
if (edge.end == edge.start)
|
||||
{
|
||||
return 0.5; // Avoid division by zero
|
||||
}
|
||||
|
||||
int clamped = max(edge.start, min(coordinate, edge.end));
|
||||
// Use int64_t to avoid overflow warning C26451
|
||||
int64_t numerator = static_cast<int64_t>(clamped) - static_cast<int64_t>(edge.start);
|
||||
int64_t denominator = static_cast<int64_t>(edge.end) - static_cast<int64_t>(edge.start);
|
||||
return static_cast<double>(numerator) / static_cast<double>(denominator);
|
||||
}
|
||||
|
||||
int MonitorTopology::GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const
|
||||
{
|
||||
// Use int64_t to prevent arithmetic overflow during subtraction and multiplication
|
||||
int64_t range = static_cast<int64_t>(edge.end) - static_cast<int64_t>(edge.start);
|
||||
int64_t offset = static_cast<int64_t>(relativePosition * static_cast<double>(range));
|
||||
// Clamp result to int range before returning
|
||||
int64_t result = static_cast<int64_t>(edge.start) + offset;
|
||||
return static_cast<int>(result);
|
||||
}
|
||||
|
||||
std::vector<MonitorTopology::GapInfo> MonitorTopology::DetectMonitorGaps() const
|
||||
{
|
||||
std::vector<GapInfo> gaps;
|
||||
const int gapThreshold = 50; // Same as ADJACENCY_TOLERANCE
|
||||
|
||||
// Check each pair of monitors
|
||||
for (size_t i = 0; i < m_monitors.size(); ++i)
|
||||
{
|
||||
for (size_t j = i + 1; j < m_monitors.size(); ++j)
|
||||
{
|
||||
const auto& m1 = m_monitors[i];
|
||||
const auto& m2 = m_monitors[j];
|
||||
|
||||
// Check vertical overlap
|
||||
int vOverlapStart = max(m1.rect.top, m2.rect.top);
|
||||
int vOverlapEnd = min(m1.rect.bottom, m2.rect.bottom);
|
||||
int vOverlap = vOverlapEnd - vOverlapStart;
|
||||
|
||||
if (vOverlap <= 0)
|
||||
{
|
||||
continue; // No vertical overlap, skip
|
||||
}
|
||||
|
||||
// Check horizontal gap
|
||||
int hGap = min(abs(m1.rect.right - m2.rect.left), abs(m2.rect.right - m1.rect.left));
|
||||
|
||||
if (hGap > gapThreshold)
|
||||
{
|
||||
GapInfo gap;
|
||||
gap.monitor1Index = static_cast<int>(i);
|
||||
gap.monitor2Index = static_cast<int>(j);
|
||||
gap.horizontalGap = hGap;
|
||||
gap.verticalOverlap = vOverlap;
|
||||
gaps.push_back(gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
HMONITOR MonitorTopology::GetMonitorFromPoint(const POINT& pt) const
|
||||
{
|
||||
return MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
|
||||
}
|
||||
|
||||
bool MonitorTopology::GetMonitorRect(HMONITOR monitor, RECT& rect) const
|
||||
{
|
||||
// First try direct HMONITOR comparison
|
||||
for (const auto& monitorInfo : m_monitors)
|
||||
{
|
||||
if (monitorInfo.hMonitor == monitor)
|
||||
{
|
||||
rect = monitorInfo.rect;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If direct comparison fails, try matching by current monitor info
|
||||
MONITORINFO mi{};
|
||||
mi.cbSize = sizeof(MONITORINFO);
|
||||
if (GetMonitorInfo(monitor, &mi))
|
||||
{
|
||||
for (const auto& monitorInfo : m_monitors)
|
||||
{
|
||||
if (monitorInfo.rect.left == mi.rcMonitor.left &&
|
||||
monitorInfo.rect.top == mi.rcMonitor.top &&
|
||||
monitorInfo.rect.right == mi.rcMonitor.right &&
|
||||
monitorInfo.rect.bottom == mi.rcMonitor.bottom)
|
||||
{
|
||||
rect = monitorInfo.rect;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
HMONITOR MonitorTopology::GetMonitorFromRect(const RECT& rect) const
|
||||
{
|
||||
return MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST);
|
||||
}
|
||||
|
||||
int MonitorTopology::GetMonitorIndex(HMONITOR monitor) const
|
||||
{
|
||||
// First try direct HMONITOR comparison (fast and accurate)
|
||||
for (size_t i = 0; i < m_monitors.size(); ++i)
|
||||
{
|
||||
if (m_monitors[i].hMonitor == monitor)
|
||||
{
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If direct comparison fails (e.g., handle changed after display reconfiguration),
|
||||
// try matching by position. Get the monitor's current rect and find matching stored rect.
|
||||
MONITORINFO mi{};
|
||||
mi.cbSize = sizeof(MONITORINFO);
|
||||
if (GetMonitorInfo(monitor, &mi))
|
||||
{
|
||||
for (size_t i = 0; i < m_monitors.size(); ++i)
|
||||
{
|
||||
// Match by rect bounds
|
||||
if (m_monitors[i].rect.left == mi.rcMonitor.left &&
|
||||
m_monitors[i].rect.top == mi.rcMonitor.top &&
|
||||
m_monitors[i].rect.right == mi.rcMonitor.right &&
|
||||
m_monitors[i].rect.bottom == mi.rcMonitor.bottom)
|
||||
{
|
||||
Logger::trace(L"GetMonitorIndex: Found monitor {} via rect fallback (handle changed from {} to {})",
|
||||
i, reinterpret_cast<uintptr_t>(m_monitors[i].hMonitor), reinterpret_cast<uintptr_t>(monitor));
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Log all stored monitors vs the requested one for debugging
|
||||
Logger::warn(L"GetMonitorIndex: No match found. Requested monitor rect=({},{},{},{})",
|
||||
mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom);
|
||||
for (size_t i = 0; i < m_monitors.size(); ++i)
|
||||
{
|
||||
Logger::warn(L" Stored monitor {}: rect=({},{},{},{})",
|
||||
i, m_monitors[i].rect.left, m_monitors[i].rect.top,
|
||||
m_monitors[i].rect.right, m_monitors[i].rect.bottom);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::warn(L"GetMonitorIndex: GetMonitorInfo failed for handle {}", reinterpret_cast<uintptr_t>(monitor));
|
||||
}
|
||||
|
||||
return -1; // Not found
|
||||
}
|
||||
|
||||
106
src/modules/MouseUtils/CursorWrap/MonitorTopology.h
Normal file
106
src/modules/MouseUtils/CursorWrap/MonitorTopology.h
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
|
||||
// Monitor information structure
|
||||
struct MonitorInfo
|
||||
{
|
||||
HMONITOR hMonitor; // Direct handle for accurate lookup after display changes
|
||||
RECT rect;
|
||||
bool isPrimary;
|
||||
int monitorId;
|
||||
};
|
||||
|
||||
// Edge type enumeration
|
||||
enum class EdgeType
|
||||
{
|
||||
Left = 0,
|
||||
Right = 1,
|
||||
Top = 2,
|
||||
Bottom = 3
|
||||
};
|
||||
|
||||
// Wrap mode enumeration (matches Settings UI dropdown)
|
||||
enum class WrapMode
|
||||
{
|
||||
Both = 0, // Wrap in both directions
|
||||
VerticalOnly = 1, // Only wrap top/bottom
|
||||
HorizontalOnly = 2 // Only wrap left/right
|
||||
};
|
||||
|
||||
// Represents a single edge of a monitor
|
||||
struct MonitorEdge
|
||||
{
|
||||
int monitorIndex; // Index into m_monitors (stable across display changes)
|
||||
EdgeType type;
|
||||
int start; // For vertical edges: Y start; horizontal: X start
|
||||
int end; // For vertical edges: Y end; horizontal: X end
|
||||
int position; // For vertical edges: X coord; horizontal: Y coord
|
||||
bool isOuter; // True if no adjacent monitor touches this edge
|
||||
};
|
||||
|
||||
// Monitor topology helper - manages edge-based monitor layout
|
||||
struct MonitorTopology
|
||||
{
|
||||
void Initialize(const std::vector<MonitorInfo>& monitors);
|
||||
|
||||
// Check if cursor is on an outer edge of the given monitor
|
||||
// wrapMode filters which edges are considered (Both, VerticalOnly, HorizontalOnly)
|
||||
bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const;
|
||||
|
||||
// Get the wrap destination point for a cursor on an outer edge
|
||||
POINT GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const;
|
||||
|
||||
// Get monitor at point (helper)
|
||||
HMONITOR GetMonitorFromPoint(const POINT& pt) const;
|
||||
|
||||
// Get monitor rectangle (helper)
|
||||
bool GetMonitorRect(HMONITOR monitor, RECT& rect) const;
|
||||
|
||||
// Get outer edges collection (for debugging)
|
||||
const std::vector<MonitorEdge>& GetOuterEdges() const { return m_outerEdges; }
|
||||
|
||||
// Detect gaps between monitors that should be snapped together
|
||||
struct GapInfo {
|
||||
int monitor1Index;
|
||||
int monitor2Index;
|
||||
int horizontalGap;
|
||||
int verticalOverlap;
|
||||
};
|
||||
std::vector<GapInfo> DetectMonitorGaps() const;
|
||||
|
||||
private:
|
||||
std::vector<MonitorInfo> m_monitors;
|
||||
std::vector<MonitorEdge> m_outerEdges;
|
||||
|
||||
// Map from (monitor index, edge type) to edge info
|
||||
// Using monitor index instead of HMONITOR because HMONITOR handles can change
|
||||
// when monitors are added/removed dynamically
|
||||
std::map<std::pair<int, EdgeType>, MonitorEdge> m_edgeMap;
|
||||
|
||||
// Helper to resolve HMONITOR to monitor index at runtime
|
||||
int GetMonitorIndex(HMONITOR monitor) const;
|
||||
|
||||
// Helper to get consistent HMONITOR from RECT
|
||||
HMONITOR GetMonitorFromRect(const RECT& rect) const;
|
||||
|
||||
void BuildEdgeMap();
|
||||
void IdentifyOuterEdges();
|
||||
|
||||
// Check if two edges are adjacent (within tolerance)
|
||||
bool EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance = 50) const;
|
||||
|
||||
// Find the opposite outer edge for wrapping
|
||||
MonitorEdge FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const;
|
||||
|
||||
// Calculate relative position along an edge (0.0 to 1.0)
|
||||
double GetRelativePosition(const MonitorEdge& edge, int coordinate) const;
|
||||
|
||||
// Convert relative position to absolute coordinate on target edge
|
||||
int GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI.ViewModels\\Microsoft.CmdPal.UI.ViewModels.csproj",
|
||||
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI\\Microsoft.CmdPal.UI.csproj",
|
||||
"src\\modules\\cmdpal\\Microsoft.Terminal.UI\\Microsoft.Terminal.UI.vcxproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Core.Common.UnitTests\\Microsoft.CmdPal.Core.Common.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Apps.UnitTests\\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",
|
||||
|
||||
@@ -9,4 +9,18 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
76
src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs
generated
Normal file
76
src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs
generated
Normal file
@@ -0,0 +1,76 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Properties {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Core.Common.Properties.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This is an error report generated by Windows Command Palette.
|
||||
///If you are seeing this, it means something went a little sideways in the app.
|
||||
///You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
///
|
||||
///(While you’re at it, give the details below a quick skim — just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.).
|
||||
/// </summary>
|
||||
internal static string ErrorReport_Global_Preamble {
|
||||
get {
|
||||
return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ErrorReport_Global_Preamble" xml:space="preserve">
|
||||
<value>This is an error report generated by Windows Command Palette.
|
||||
If you are seeing this, it means something went a little sideways in the app.
|
||||
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
|
||||
(While you’re at it, give the details below a quick skim — just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.)</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
|
||||
public sealed class ErrorReportBuilder : IErrorReportBuilder
|
||||
{
|
||||
private readonly ErrorReportSanitizer _sanitizer = new();
|
||||
|
||||
private static string Preamble => Properties.Resources.ErrorReport_Global_Preamble;
|
||||
|
||||
public string BuildReport(Exception exception, string context, bool redactPii = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
|
||||
var exceptionMessage = CoalesceExceptionMessage(exception);
|
||||
var sanitizedMessage = redactPii ? _sanitizer.Sanitize(exceptionMessage) : exceptionMessage;
|
||||
var sanitizedFormattedException = redactPii ? _sanitizer.Sanitize(exception.ToString()) : exception.ToString();
|
||||
|
||||
// Note:
|
||||
// - do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
// - keep timestamp format should be consistent with the log (makes it easier to search)
|
||||
var technicalContent =
|
||||
$"""
|
||||
============================================================
|
||||
Summary:
|
||||
Message: {sanitizedMessage}
|
||||
Type: {exception.GetType().FullName}
|
||||
Source: {exception.Source ?? "N/A"}
|
||||
Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fffffff}
|
||||
HRESULT: 0x{exception.HResult:X8} ({exception.HResult})
|
||||
Context: {context ?? "N/A"}
|
||||
|
||||
Application:
|
||||
App version: {GetAppVersionSafe()}
|
||||
Is elevated: {GetElevationStatus()}
|
||||
|
||||
Environment:
|
||||
OS version: {RuntimeInformation.OSDescription}
|
||||
OS architecture: {RuntimeInformation.OSArchitecture}
|
||||
Runtime identifier: {RuntimeInformation.RuntimeIdentifier}
|
||||
Framework: {RuntimeInformation.FrameworkDescription}
|
||||
Process architecture: {RuntimeInformation.ProcessArchitecture}
|
||||
Culture: {CultureInfo.CurrentCulture.Name}
|
||||
UI culture: {CultureInfo.CurrentUICulture.Name}
|
||||
|
||||
Stack Trace:
|
||||
{exception.StackTrace}
|
||||
|
||||
------------------ Full Exception Details ------------------
|
||||
{sanitizedFormattedException}
|
||||
|
||||
============================================================
|
||||
""";
|
||||
|
||||
return $"""
|
||||
{Preamble}
|
||||
{technicalContent}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string GetElevationStatus()
|
||||
{
|
||||
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
try
|
||||
{
|
||||
var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
return isElevated ? "yes" : "no";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "Failed to determine elevation status";
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAppVersionSafe()
|
||||
{
|
||||
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
try
|
||||
{
|
||||
var version = Package.Current.Id.Version;
|
||||
return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "Failed to retrieve app version";
|
||||
}
|
||||
}
|
||||
|
||||
private static string CoalesceExceptionMessage(Exception exception)
|
||||
{
|
||||
// let's try to get a message from the exception or inferred it from the HRESULT
|
||||
// to show at least something
|
||||
var message = exception.Message;
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
var temp = Marshal.GetExceptionForHR(exception.HResult)?.Message;
|
||||
if (!string.IsNullOrWhiteSpace(temp))
|
||||
{
|
||||
message = temp + $" (inferred from HRESULT 0x{exception.HResult:X8})";
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
message = "No message available";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for creating human-readable error reports from exceptions,
|
||||
/// suitable for logs, telemetry, or user-facing diagnostics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations should ensure reports are consistent and optionally redact
|
||||
/// personally identifiable or sensitive information when requested.
|
||||
/// </remarks>
|
||||
public interface IErrorReportBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a formatted error report for the specified <paramref name="exception"/> and <paramref name="context"/>.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception that triggered the error report.</param>
|
||||
/// <param name="context">
|
||||
/// A short, human-readable description of where or what was being executed when the error occurred
|
||||
/// (e.g., the operation name, component, or scenario).
|
||||
/// </param>
|
||||
/// <param name="redactPii">
|
||||
/// When true, attempts to remove or obfuscate personally identifiable or sensitive information
|
||||
/// (such as file paths, emails, machine/usernames, tokens). Defaults to true.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// A formatted string containing the error report, suitable for logging or telemetry submission.
|
||||
/// </returns>
|
||||
string BuildReport(Exception exception, string context, bool redactPii = true);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a service that sanitizes text by applying a set of configurable, regex-based rules.
|
||||
/// Typical use cases include masking secrets, removing PII, or normalizing logs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// - Rules are applied in their registered order; rule ordering may affect the final output.
|
||||
/// - Each rule should have a unique <c>description</c> that acts as its identifier.
|
||||
/// </remarks>
|
||||
/// <seealso cref="SanitizationRule"/>
|
||||
public interface ITextSanitizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Sanitizes the specified input by applying all registered rules in order.
|
||||
/// </summary>
|
||||
/// <param name="input">The input text to sanitize. Implementations should handle <see langword="null"/> safely.</param>
|
||||
/// <returns>The sanitized text after all rules are applied.</returns>
|
||||
string Sanitize(string? input);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a sanitization rule using a .NET regular expression pattern and a replacement string.
|
||||
/// </summary>
|
||||
/// <param name="pattern">A .NET regular expression pattern used to match text to sanitize.</param>
|
||||
/// <param name="replacement">
|
||||
/// The replacement text used by <c>Regex.Replace</c>. Supports standard regex replacement tokens,
|
||||
/// including numbered groups (<c>$1</c>) and named groups (<c>${name}</c>).
|
||||
/// </param>
|
||||
/// <param name="description">
|
||||
/// A human-readable, unique identifier for the rule. Used to list, test, and remove the rule.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Implementations typically validate <paramref name="pattern"/> is a valid regex and may reject duplicate <paramref name="description"/> values.
|
||||
/// </remarks>
|
||||
void AddRule(string pattern, string replacement, string description = "");
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously added rule identified by its <paramref name="description"/>.
|
||||
/// </summary>
|
||||
/// <param name="description">The unique description of the rule to remove.</param>
|
||||
void RemoveRule(string description);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a read-only snapshot of the currently registered sanitization rules in application order.
|
||||
/// </summary>
|
||||
/// <returns>A read-only list of <see cref="SanitizationRule"/> items.</returns>
|
||||
IReadOnlyList<SanitizationRule> GetRules();
|
||||
|
||||
/// <summary>
|
||||
/// Tests a single rule, identified by <paramref name="ruleDescription"/>, against the provided <paramref name="input"/>,
|
||||
/// without applying other rules.
|
||||
/// </summary>
|
||||
/// <param name="input">The input text to test.</param>
|
||||
/// <param name="ruleDescription">The description (identifier) of the rule to test.</param>
|
||||
/// <returns>The result of applying only the specified rule to the input.</returns>
|
||||
string TestRule(string input, string ruleDescription);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
public readonly record struct SanitizationRule
|
||||
{
|
||||
public SanitizationRule(Regex regex, string replacement, string description = "")
|
||||
{
|
||||
Regex = regex;
|
||||
Replacement = replacement;
|
||||
Evaluator = null;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public SanitizationRule(Regex regex, MatchEvaluator evaluator, string description = "")
|
||||
{
|
||||
Regex = regex;
|
||||
Evaluator = evaluator;
|
||||
Replacement = null;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public Regex Regex { get; }
|
||||
|
||||
public string? Replacement { get; }
|
||||
|
||||
public MatchEvaluator? Evaluator { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public override string ToString() => $"{Description}: {Regex} -> {Replacement ?? "<evaluator>"}";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class ConnectionStringRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
[GeneratedRegex(@"(Server|Data Source|Initial Catalog|Database|User ID|Username|Password|Pwd|Uid)\s*=\s*(?:""[^""]*""|'[^']*'|[^;,\s]+)",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex ConnectionParamRx();
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(ConnectionParamRx(), "$1=[REDACTED]", "Connection string parameters");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class EnvironmentPropertiesRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
List<SanitizationRule> rules = [];
|
||||
|
||||
var machine = Environment.MachineName;
|
||||
if (!string.IsNullOrWhiteSpace(machine))
|
||||
{
|
||||
var rx = new Regex(@"\b" + Regex.Escape(machine) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
rules.Add(new(rx, "[MACHINE_NAME_REDACTED]", "Machine name"));
|
||||
}
|
||||
|
||||
var domain = Environment.UserDomainName;
|
||||
if (!string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
var rx = new Regex(@"\b" + Regex.Escape(domain) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
rules.Add(new(rx, "[USER_DOMAIN_NAME_REDACTED]", "User domain name"));
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
/// <summary>
|
||||
/// Specific sanitizer used for error report content. Builds on top of the generic TextSanitizer.
|
||||
/// </summary>
|
||||
public sealed class ErrorReportSanitizer
|
||||
{
|
||||
private readonly TextSanitizer _sanitizer = new(BuildProviders(), onGuardrailTriggered: OnGuardrailTriggered);
|
||||
|
||||
private static void OnGuardrailTriggered(GuardrailEventArgs eventArgs)
|
||||
{
|
||||
var msg = $"Sanitization guardrail triggered for rule '{eventArgs.RuleDescription}': original length={eventArgs.OriginalLength}, result length={eventArgs.ResultLength}, ratio={eventArgs.Ratio:F2}, threshold={eventArgs.Threshold:F2}";
|
||||
CoreLogger.LogDebug(msg);
|
||||
}
|
||||
|
||||
private static IEnumerable<ISanitizationRuleProvider> BuildProviders()
|
||||
{
|
||||
// Order matters
|
||||
return
|
||||
[
|
||||
new PiiRuleProvider(),
|
||||
new UrlRuleProvider(),
|
||||
new NetworkRuleProvider(),
|
||||
new TokenRuleProvider(),
|
||||
new ConnectionStringRuleProvider(),
|
||||
new SecretKeyValueRulesProvider(),
|
||||
new EnvironmentPropertiesRuleProvider(),
|
||||
new FilenameMaskRuleProvider(),
|
||||
new ProfilePathAndUsernameRuleProvider()
|
||||
];
|
||||
}
|
||||
|
||||
public string Sanitize(string? input) => _sanitizer.Sanitize(input);
|
||||
|
||||
public string SanitizeException(Exception? exception)
|
||||
{
|
||||
if (exception is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var fullMessage = GetFullExceptionMessage(exception);
|
||||
return Sanitize(fullMessage);
|
||||
}
|
||||
|
||||
private static string GetFullExceptionMessage(Exception exception)
|
||||
{
|
||||
List<string> messages = [];
|
||||
var current = exception;
|
||||
var depth = 0;
|
||||
|
||||
// Prevent infinite loops on pathological InnerException graphs
|
||||
while (current is not null && depth < 10)
|
||||
{
|
||||
messages.Add($"{current.GetType().Name}: {current.Message}");
|
||||
|
||||
if (!string.IsNullOrEmpty(current.StackTrace))
|
||||
{
|
||||
messages.Add($"Stack Trace: {current.StackTrace}");
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, messages);
|
||||
}
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
=> _sanitizer.AddRule(pattern, replacement, description);
|
||||
|
||||
public void RemoveRule(string description)
|
||||
=> _sanitizer.RemoveRule(description);
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _sanitizer.GetRules();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
=> _sanitizer.TestRule(input, ruleDescription);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
private static readonly FrozenSet<string> CommonFileStemExclusions = new[]
|
||||
{
|
||||
"settings",
|
||||
"config",
|
||||
"configuration",
|
||||
"appsettings",
|
||||
"options",
|
||||
"prefs",
|
||||
"preferences",
|
||||
"squirrel",
|
||||
"app",
|
||||
"system",
|
||||
"env",
|
||||
"environment",
|
||||
"manifest",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
const string pattern = """
|
||||
(?<full>
|
||||
(?: [A-Za-z]: )? (?: [\\/][^\\/:*?""<>|\s]+ )+ # drive-rooted or UNC-like
|
||||
| [^\\/:*?""<>|\s]+ (?: [\\/][^\\/:*?""<>|\s]+ )+ # relative with at least one sep
|
||||
)
|
||||
""";
|
||||
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
yield return new SanitizationRule(rx, MatchEvaluator, "Mask filename in any path");
|
||||
yield break;
|
||||
|
||||
static string MatchEvaluator(Match m)
|
||||
{
|
||||
var full = m.Groups["full"].Value;
|
||||
|
||||
var lastSep = Math.Max(full.LastIndexOf('\\'), full.LastIndexOf('/'));
|
||||
if (lastSep < 0 || lastSep == full.Length - 1)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
var dir = full[..(lastSep + 1)];
|
||||
var file = full[(lastSep + 1)..];
|
||||
|
||||
var dot = file.LastIndexOf('.');
|
||||
var looksLikeFile = (dot > 0 && dot < file.Length - 1) || (file.StartsWith('.') && file.Length > 1);
|
||||
|
||||
if (!looksLikeFile)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
string stem, ext;
|
||||
if (dot > 0 && dot < file.Length - 1)
|
||||
{
|
||||
stem = file[..dot];
|
||||
ext = file[dot..];
|
||||
}
|
||||
else
|
||||
{
|
||||
stem = file;
|
||||
ext = string.Empty;
|
||||
}
|
||||
|
||||
if (!ShouldMaskFileName(stem))
|
||||
{
|
||||
return dir + file;
|
||||
}
|
||||
|
||||
var masked = MaskStem(stem) + ext;
|
||||
return dir + masked;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeStem(string stem)
|
||||
{
|
||||
return stem.Replace("-", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(".", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool ShouldMaskFileName(string stem)
|
||||
{
|
||||
return !CommonFileStemExclusions.Contains(NormalizeStem(stem));
|
||||
}
|
||||
|
||||
private static string MaskStem(string stem)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stem))
|
||||
{
|
||||
return stem;
|
||||
}
|
||||
|
||||
var keep = Math.Min(2, stem.Length);
|
||||
var maskedCount = Math.Max(1, stem.Length - keep);
|
||||
return stem[..keep] + new string('*', maskedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
public record GuardrailEventArgs(
|
||||
string RuleDescription,
|
||||
int OriginalLength,
|
||||
int ResultLength,
|
||||
double Threshold)
|
||||
{
|
||||
public double Ratio => OriginalLength > 0 ? (double)ResultLength / OriginalLength : 1.0;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal interface ISanitizationRuleProvider
|
||||
{
|
||||
IEnumerable<SanitizationRule> GetRules();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class NetworkRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses");
|
||||
yield return new(Ipv6BracketedRx(), "[IP6_REDACTED]", "IPv6 addresses (bracketed/with port)");
|
||||
yield return new(Ipv6Rx(), "[IP6_REDACTED]", "IPv6 addresses");
|
||||
yield return new(MacAddressRx(), "[MAC_ADDRESS_REDACTED]", "MAC addresses");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv4Rx();
|
||||
|
||||
[GeneratedRegex(
|
||||
"""
|
||||
(?ix) # ignore case/whitespace
|
||||
(?<![A-F0-9:]) # left edge
|
||||
(
|
||||
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} | # 1:2:3:4:5:6:7:8
|
||||
(?:[A-F0-9]{1,4}:){1,7}: | # 1:: 1:2:...:7::
|
||||
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
|
||||
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
|
||||
:(?::[A-F0-9]{1,4}){1,7} | # ::, ::1, etc.
|
||||
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} | # IPv4 tail
|
||||
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
:(?:\d{1,3}\.){3}\d{1,3}
|
||||
)
|
||||
(?:%\w+)? # optional zone id
|
||||
(?![A-F0-9:]) # right edge
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv6Rx();
|
||||
|
||||
[GeneratedRegex(
|
||||
"""
|
||||
(?ix)
|
||||
\[
|
||||
(
|
||||
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,7}: |
|
||||
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
|
||||
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
|
||||
:(?::[A-F0-9]{1,4}){1,7} |
|
||||
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
:(?:\d{1,3}\.){3}\d{1,3}
|
||||
)
|
||||
(?:%\w+)? # optional zone id
|
||||
\]
|
||||
(?: : (?<port>\d{1,5}) )? # optional port
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv6BracketedRx();
|
||||
|
||||
[GeneratedRegex(@"\b(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}|[0-9A-Fa-f]{1,2})\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex MacAddressRx();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class PiiRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(EmailRx(), "[EMAIL_REDACTED]", "Email addresses");
|
||||
yield return new(SsnRx(), "[SSN_REDACTED]", "Social Security Numbers");
|
||||
yield return new(CreditCardRx(), "[CARD_REDACTED]", "Credit card numbers");
|
||||
|
||||
// phone number regex is the most generic, so it goes last
|
||||
// we can't make this too generic; otherwise we over-redact error codes, dates, etc.
|
||||
yield return new(PhoneRx(), "[PHONE_REDACTED]", "Phone numbers");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b[a-zA-Z0-9]([a-zA-Z0-9._%-]*[a-zA-Z0-9])?@[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex EmailRx();
|
||||
|
||||
[GeneratedRegex("""
|
||||
(?xi)
|
||||
# ---------- boundaries ----------
|
||||
(?<!\w) # not after a letter/digit/underscore
|
||||
(?<![A-Za-z0-9]-) # avoid starting inside hyphenated tokens (GUID middles, etc.)
|
||||
|
||||
# ---------- global do-not-match guards ----------
|
||||
(?! # ISO date (yyyy-mm-dd / yyyy.mm.dd / yyyy/mm/dd)
|
||||
(?:19|20)\d{2}[-./](?:0[1-9]|1[0-2])[-./](?:0[1-9]|[12]\d|3[01])\b
|
||||
)
|
||||
(?! # EU date (dd-mm-yyyy / dd.mm.yyyy / dd/mm/yyyy)
|
||||
(?:0[1-9]|[12]\d|3[01])[-./](?:0[1-9]|1[0-2])[-./](?:19|20)\d{2}\b
|
||||
)
|
||||
(?! # ISO datetime like 2025-08-24T14:32[:ss][Z|±hh:mm]
|
||||
(?:19|20)\d{2}-\d{2}-\d{2}[T\s]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\b
|
||||
)
|
||||
(?!\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b) # IPv4 with optional :port
|
||||
(?!\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b) # GUID, lowercase
|
||||
(?!\b[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}\b) # GUID, uppercase
|
||||
(?!\bv?\d+(?:\.\d+){2,}\b) # semantic/file versions like 1.2.3 or 10.0.22631.3448
|
||||
(?!\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b) # MAC address
|
||||
|
||||
# ---------- digit budget ----------
|
||||
(?=(?:\D*\d){7,15}) # 7–15 digits in total
|
||||
|
||||
# ---------- number body ----------
|
||||
(?:
|
||||
# A with explicit country code, allow compact digits (E.164-ish) or grouped
|
||||
(?:\+|00)[1-9]\d{0,2}
|
||||
(?:
|
||||
[\p{Zs}.\-\/]*\d{6,14}
|
||||
|
|
||||
[\p{Zs}.\-\/]* (?:\(\d{1,4}\)|\d{1,4})
|
||||
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
|
||||
)
|
||||
|
|
||||
# B no country code => require separators between blocks (avoid plain big ints)
|
||||
(?:\(\d{1,4}\)|\d{1,4})
|
||||
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
|
||||
)
|
||||
|
||||
# ---------- optional extension ----------
|
||||
(?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?<ext>\d{1,6}))?
|
||||
|
||||
(?!-\w) # don't end just before '-letter'/'-digit'
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex PhoneRx();
|
||||
|
||||
[GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex SsnRx();
|
||||
|
||||
[GeneratedRegex(@"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex CreditCardRx();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class ProfilePathAndUsernameRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs);
|
||||
|
||||
private readonly Dictionary<string, string> _profilePaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _usernames = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly FrozenSet<string> CommonPathParts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Users", "home", "Documents", "Desktop", "AppData", "Local", "Roaming",
|
||||
"Pictures", "Videos", "Music", "Downloads", "Program Files", "Windows",
|
||||
"System32", "bin", "usr", "var", "etc", "opt", "tmp",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly FrozenSet<string> CommonWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"admin", "user", "test", "guest", "public", "system", "service",
|
||||
"default", "temp", "local", "shared", "common", "data", "config",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ProfilePathAndUsernameRuleProvider()
|
||||
{
|
||||
DetectSystemPaths();
|
||||
}
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
List<SanitizationRule> rules = [];
|
||||
|
||||
// Profile path rules (ordered longest-first)
|
||||
var orderedRules = _profilePaths
|
||||
.Where(p => !string.IsNullOrEmpty(p.Key))
|
||||
.OrderByDescending(p => p.Key.Length);
|
||||
|
||||
foreach (var profilePath in orderedRules)
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalizedPath = profilePath.Key
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar);
|
||||
var escapedPath = Regex.Escape(normalizedPath);
|
||||
|
||||
var pattern = escapedPath + @"(?:[/\\]*)";
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
|
||||
rules.Add(new(rx, profilePath.Value, $"Profile path: {profilePath}"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip problematic paths
|
||||
}
|
||||
}
|
||||
|
||||
// Username rules
|
||||
foreach (var username in _usernames.Where(u => !string.IsNullOrEmpty(u) && u.Length > 2))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsLikelyUsername(username))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rx = new Regex(@"\b" + Regex.Escape(username) + @"\b", SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
rules.Add(new(rx, "[USERNAME_REDACTED]", $"Username: {username}"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip problematic usernames
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> GetDetectedProfilePaths() => _profilePaths;
|
||||
|
||||
public IReadOnlyCollection<string> GetDetectedUsernames() => _usernames;
|
||||
|
||||
private void DetectSystemPaths()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(userProfile) && Directory.Exists(userProfile))
|
||||
{
|
||||
_profilePaths.Add(userProfile, "[USER_PROFILE_DIR]");
|
||||
var username = Path.GetFileName(userProfile);
|
||||
if (!string.IsNullOrEmpty(username) && username.Length > 2)
|
||||
{
|
||||
_usernames.Add(username);
|
||||
}
|
||||
}
|
||||
|
||||
Environment.SpecialFolder[] profileFolders =
|
||||
[
|
||||
Environment.SpecialFolder.ApplicationData,
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolder.Desktop,
|
||||
Environment.SpecialFolder.MyDocuments,
|
||||
Environment.SpecialFolder.MyPictures,
|
||||
Environment.SpecialFolder.MyVideos,
|
||||
Environment.SpecialFolder.MyMusic,
|
||||
Environment.SpecialFolder.StartMenu,
|
||||
Environment.SpecialFolder.Startup,
|
||||
Environment.SpecialFolder.DesktopDirectory
|
||||
];
|
||||
|
||||
foreach (var folder in profileFolders)
|
||||
{
|
||||
var dir = Environment.GetFolderPath(folder);
|
||||
if (string.IsNullOrEmpty(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var added = _profilePaths.TryAdd(dir, $"[{folder.ToString().ToUpperInvariant()}_DIR]");
|
||||
if (!added)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
string[] envVars = ["USERPROFILE", "HOME", "OneDrive", "OneDriveCommercial"];
|
||||
foreach (var envVar in envVars)
|
||||
{
|
||||
var envPath = Environment.GetEnvironmentVariable(envVar);
|
||||
if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
|
||||
{
|
||||
_profilePaths.TryAdd(envPath, $"[{envVar.ToUpperInvariant()}_DIR]");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Error detecting system profile paths and usernames", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLikelyUsername(string username) =>
|
||||
!CommonWords.Contains(username) &&
|
||||
username.Length is >= 3 and <= 50 &&
|
||||
!username.All(char.IsDigit);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal static class SanitizerDefaults
|
||||
{
|
||||
public const RegexOptions DefaultOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled;
|
||||
public const int DefaultMatchTimeoutMs = 100;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class SecretKeyValueRulesProvider : ISanitizationRuleProvider
|
||||
{
|
||||
// Central list of common secret keys/phrases to redact when found in key=value pairs.
|
||||
private static readonly FrozenSet<string> SecretKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Core passwords/secrets
|
||||
"password",
|
||||
"passphrase",
|
||||
"passwd",
|
||||
"pwd",
|
||||
|
||||
// Tokens
|
||||
"token",
|
||||
"access token",
|
||||
"refresh token",
|
||||
"id token",
|
||||
"auth token",
|
||||
"session token",
|
||||
"bearer token",
|
||||
"personal access token",
|
||||
"pat",
|
||||
|
||||
// API / client credentials
|
||||
"api key",
|
||||
"api secret",
|
||||
"x api key",
|
||||
"client id",
|
||||
"client secret",
|
||||
"x client id",
|
||||
"x client secret",
|
||||
"consumer secret",
|
||||
"service principal secret",
|
||||
|
||||
// Cloud & platform (Azure/AppInsights/etc.)
|
||||
"subscription key",
|
||||
"instrumentation key",
|
||||
"account key",
|
||||
"storage account key",
|
||||
"shared access key",
|
||||
"shared access signature",
|
||||
"SAS token",
|
||||
|
||||
// Connection strings (often surfaced in exception messages)
|
||||
"connection string",
|
||||
"conn string",
|
||||
"storage connection string",
|
||||
|
||||
// Certificates & crypto
|
||||
"private key",
|
||||
"certificate password",
|
||||
"client certificate password",
|
||||
"pfx password",
|
||||
|
||||
// AWS common keys
|
||||
"aws access key id",
|
||||
"aws secret access key",
|
||||
"aws session token",
|
||||
|
||||
// Optional service aliases
|
||||
"cosmos db key",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return BuildSecretKeyValueRule(
|
||||
SecretKeys,
|
||||
timeout: TimeSpan.FromSeconds(5),
|
||||
starEverything: true);
|
||||
}
|
||||
|
||||
private static SanitizationRule BuildSecretKeyValueRule(
|
||||
IEnumerable<string> keys,
|
||||
RegexOptions? options = null,
|
||||
TimeSpan? timeout = null,
|
||||
string label = "[REDACTED]",
|
||||
bool treatDashUnderscoreAsSpace = true,
|
||||
string separatorsClass = "[:=]", // char class for separators
|
||||
string unquotedStopClass = "\\s",
|
||||
bool starEverything = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
|
||||
// Between-word matcher for keys: "api key" -> "api\s*key" (optionally treating _/- as "space")
|
||||
var between = treatDashUnderscoreAsSpace ? @"(?:\s|[_-])*" : @"\s*";
|
||||
|
||||
var patterns = new List<string>();
|
||||
|
||||
foreach (var raw in keys)
|
||||
{
|
||||
var key = raw?.Trim();
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starEverything && key is not ['*', ..])
|
||||
{
|
||||
key = "*" + key;
|
||||
}
|
||||
|
||||
if (key is ['*', .. var tail])
|
||||
{
|
||||
// Wildcard prefix: allow one non-space token + optional "-" or "_" before the remainder.
|
||||
// Matches: "api key", "api-key", "azure-api-key", "user_api_key"
|
||||
var remainder = tail.Trim();
|
||||
if (remainder.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rem = Normalize(remainder, between);
|
||||
patterns.Add($@"(?:(?>[A-Za-z0-9_]{{1,128}}[_-]))?{rem}");
|
||||
}
|
||||
else
|
||||
{
|
||||
patterns.Add(Normalize(key, between));
|
||||
}
|
||||
}
|
||||
|
||||
if (patterns.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("No non-empty keys provided.", nameof(keys));
|
||||
}
|
||||
|
||||
var keysAlt = string.Join("|", patterns);
|
||||
|
||||
var pattern =
|
||||
$"""
|
||||
# Negative lookbehind to ensure the key is not part of a larger word
|
||||
(?<![A-Za-z0-9])
|
||||
# Match and capture the key (from the provided list)
|
||||
(?<key>(?:{keysAlt}))
|
||||
# Negative lookahead to ensure the key is not part of a larger word
|
||||
(?![A-Za-z0-9])
|
||||
# Optional whitespace between key and separator
|
||||
\s*
|
||||
# Separator (e.g., ':' or '=')
|
||||
(?<sep>{separatorsClass})
|
||||
# Optional whitespace after separator
|
||||
\s*
|
||||
# Match and capture the value, supporting quoted or unquoted values
|
||||
(?:
|
||||
# Quoted value: match opening quote, value, and closing quote
|
||||
(?<q>["'])(?<val>[^"']+)\k<q>
|
||||
|
|
||||
# Unquoted value: match up to the next whitespace
|
||||
(?<val>[^{unquotedStopClass}]+)
|
||||
)
|
||||
""";
|
||||
|
||||
var rx = new Regex(
|
||||
pattern,
|
||||
(options ?? (RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) | RegexOptions.IgnorePatternWhitespace,
|
||||
timeout ?? TimeSpan.FromMilliseconds(1000));
|
||||
|
||||
var replacement = @"${key}${sep} ${q}" + label + @"${q}";
|
||||
return new SanitizationRule(rx, replacement, "Sensitive key/value pairs");
|
||||
|
||||
static string Normalize(string s, string betweenSep)
|
||||
=> Regex.Escape(s).Replace("\\ ", betweenSep);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
/// <summary>
|
||||
/// Generic text sanitizer that applies a sequence of regex-based rules over input text.
|
||||
/// </summary>
|
||||
internal sealed class TextSanitizer : ITextSanitizer
|
||||
{
|
||||
// Default guardrail: sanitized text must retain at least 30% of the original length
|
||||
private const double DefaultGuardrailThreshold = 0.3;
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs);
|
||||
|
||||
private readonly List<SanitizationRule> _rules = [];
|
||||
private readonly double _guardrailThreshold;
|
||||
private readonly Action<GuardrailEventArgs>? _onGuardrailTriggered;
|
||||
|
||||
public TextSanitizer(
|
||||
double guardrailThreshold = DefaultGuardrailThreshold,
|
||||
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
|
||||
{
|
||||
_guardrailThreshold = guardrailThreshold;
|
||||
_onGuardrailTriggered = onGuardrailTriggered;
|
||||
}
|
||||
|
||||
public TextSanitizer(
|
||||
IEnumerable<ISanitizationRuleProvider> providers,
|
||||
double guardrailThreshold = DefaultGuardrailThreshold,
|
||||
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providers);
|
||||
_guardrailThreshold = guardrailThreshold;
|
||||
_onGuardrailTriggered = onGuardrailTriggered;
|
||||
|
||||
foreach (var p in providers)
|
||||
{
|
||||
try
|
||||
{
|
||||
_rules.AddRange(p.GetRules());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort; ignore provider errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Sanitize(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = input;
|
||||
|
||||
foreach (var rule in _rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
var previous = result;
|
||||
|
||||
result = rule.Evaluator is null
|
||||
? rule.Regex.Replace(previous, rule.Replacement!)
|
||||
: rule.Regex.Replace(previous, rule.Evaluator);
|
||||
|
||||
if (result.Length < previous.Length * _guardrailThreshold)
|
||||
{
|
||||
_onGuardrailTriggered?.Invoke(new GuardrailEventArgs(
|
||||
rule.Description,
|
||||
previous.Length,
|
||||
result.Length,
|
||||
_guardrailThreshold));
|
||||
result = previous; // Guardrail
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// Ignore timeouts; keep the original input
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore other exceptions; keep the original input
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
{
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
_rules.Add(new SanitizationRule(rx, replacement, description));
|
||||
}
|
||||
|
||||
public void RemoveRule(string description)
|
||||
{
|
||||
_rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _rules.AsReadOnly();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
{
|
||||
var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase));
|
||||
if (rule.Regex is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (rule.Evaluator is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Evaluator);
|
||||
}
|
||||
|
||||
if (rule.Replacement is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Replacement);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore exceptions; return original input
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class TokenRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(JwtRx(), "[JWT_REDACTED]", "JSON Web Tokens (JWT)");
|
||||
yield return new(TokenRx(), "[TOKEN_REDACTED]", "Potential API keys/tokens");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex JwtRx();
|
||||
|
||||
[GeneratedRegex(@"\b[A-Za-z0-9]{32,128}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex TokenRx();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class UrlRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(UrlRx(), "[URL_REDACTED]", "URLs");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b(?:https?|ftp|ftps|file|jdbc|ldap|mailto)://[^\s<>""'{}\[\]\\^`|]+",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex UrlRx();
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public partial class ContextMenuViewModel : ObservableObject,
|
||||
{
|
||||
if (SelectedItem is not null)
|
||||
{
|
||||
if (SelectedItem.MoreCommands.Count() > 1)
|
||||
if (SelectedItem.PrimaryCommand is not null || SelectedItem.HasMoreCommands)
|
||||
{
|
||||
ContextMenuStack.Clear();
|
||||
PushContextStack(SelectedItem.AllCommands);
|
||||
|
||||
@@ -43,4 +43,10 @@
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>$(AssemblyName).UnitTests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
26
src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf
Normal file
26
src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"solution": {
|
||||
"path": "..\\..\\..\\PowerToys.slnx",
|
||||
"projects": [
|
||||
"src\\common\\Common.Search\\Common.Search.csproj",
|
||||
"src\\common\\Common.UI\\Common.UI.csproj",
|
||||
"src\\common\\ManagedCommon\\ManagedCommon.csproj",
|
||||
"src\\common\\ManagedTelemetry\\Telemetry\\ManagedTelemetry.csproj",
|
||||
"src\\common\\PowerToys.ModuleContracts\\PowerToys.ModuleContracts.csproj",
|
||||
"src\\common\\SettingsAPI\\SettingsAPI.vcxproj",
|
||||
"src\\common\\interop\\PowerToys.Interop.vcxproj",
|
||||
"src\\common\\logger\\logger.vcxproj",
|
||||
"src\\common\\version\\version.vcxproj",
|
||||
"src\\logging\\logging.vcxproj",
|
||||
"src\\modules\\MouseUtils\\MouseJump.Common\\MouseJump.Common.csproj",
|
||||
"src\\modules\\Workspaces\\Workspaces.ModuleServices\\Workspaces.ModuleServices.csproj",
|
||||
"src\\modules\\Workspaces\\WorkspacesCsharpLibrary\\WorkspacesCsharpLibrary.csproj",
|
||||
"src\\modules\\ZoomIt\\ZoomItSettingsInterop\\ZoomItSettingsInterop.vcxproj",
|
||||
"src\\modules\\awake\\Awake.ModuleServices\\Awake.ModuleServices.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.PowerToys\\Microsoft.CmdPal.Ext.PowerToys.csproj",
|
||||
"src\\modules\\colorPicker\\ColorPicker.ModuleServices\\ColorPicker.ModuleServices.csproj",
|
||||
"src\\modules\\fancyzones\\FancyZonesEditorCommon\\FancyZonesEditorCommon.csproj",
|
||||
"src\\settings-ui\\Settings.UI.Library\\Settings.UI.Library.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.Core.ViewModels.Models;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -23,6 +24,8 @@ public sealed class CommandProviderWrapper
|
||||
|
||||
private readonly TaskScheduler _taskScheduler;
|
||||
|
||||
private readonly ICommandProviderCache? _commandProviderCache;
|
||||
|
||||
public TopLevelViewModel[] TopLevelItems { get; private set; } = [];
|
||||
|
||||
public TopLevelViewModel[] FallbackItems { get; private set; } = [];
|
||||
@@ -43,13 +46,7 @@ public sealed class CommandProviderWrapper
|
||||
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
public string ProviderId
|
||||
{
|
||||
get
|
||||
{
|
||||
return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
|
||||
}
|
||||
}
|
||||
public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
|
||||
|
||||
public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread)
|
||||
{
|
||||
@@ -77,9 +74,11 @@ public sealed class CommandProviderWrapper
|
||||
Logger.LogDebug($"Initialized command provider {ProviderId}");
|
||||
}
|
||||
|
||||
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread)
|
||||
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread, ICommandProviderCache commandProviderCache)
|
||||
{
|
||||
_taskScheduler = mainThread;
|
||||
_commandProviderCache = commandProviderCache;
|
||||
|
||||
Extension = extension;
|
||||
ExtensionHost = new CommandPaletteHost(extension);
|
||||
if (!Extension.IsRunning())
|
||||
@@ -128,30 +127,31 @@ public sealed class CommandProviderWrapper
|
||||
if (!isValid)
|
||||
{
|
||||
IsActive = false;
|
||||
RecallFromCache();
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = serviceProvider.GetService<SettingsModel>()!;
|
||||
|
||||
IsActive = GetProviderSettings(settings).IsEnabled;
|
||||
var providerSettings = GetProviderSettings(settings);
|
||||
IsActive = providerSettings.IsEnabled;
|
||||
if (!IsActive)
|
||||
{
|
||||
RecallFromCache();
|
||||
return;
|
||||
}
|
||||
|
||||
ICommandItem[]? commands = null;
|
||||
IFallbackCommandItem[]? fallbacks = null;
|
||||
|
||||
var displayInfoInitialized = false;
|
||||
try
|
||||
{
|
||||
var model = _commandProvider.Unsafe!;
|
||||
|
||||
Task<ICommandItem[]> t = new(model.TopLevelCommands);
|
||||
t.Start();
|
||||
commands = await t.ConfigureAwait(false);
|
||||
Task<ICommandItem[]> loadTopLevelCommandsTask = new(model.TopLevelCommands);
|
||||
loadTopLevelCommandsTask.Start();
|
||||
var commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
|
||||
|
||||
// On a BG thread here
|
||||
fallbacks = model.FallbackCommands();
|
||||
var fallbacks = model.FallbackCommands();
|
||||
|
||||
if (model is ICommandProvider2 two)
|
||||
{
|
||||
@@ -162,6 +162,13 @@ public sealed class CommandProviderWrapper
|
||||
DisplayName = model.DisplayName;
|
||||
Icon = new(model.Icon);
|
||||
Icon.InitializeProperties();
|
||||
displayInfoInitialized = true;
|
||||
|
||||
// Update cached display name
|
||||
if (_commandProviderCache is not null && Extension?.ExtensionUniqueId is not null)
|
||||
{
|
||||
_commandProviderCache.Memorize(Extension.ExtensionUniqueId, new CommandProviderCacheItem(model.DisplayName));
|
||||
}
|
||||
|
||||
// Note: explicitly not InitializeProperties()ing the settings here. If
|
||||
// we do that, then we'd regress GH #38321
|
||||
@@ -177,6 +184,25 @@ public sealed class CommandProviderWrapper
|
||||
Logger.LogError("Failed to load commands from extension");
|
||||
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
|
||||
Logger.LogError(e.ToString());
|
||||
|
||||
if (!displayInfoInitialized)
|
||||
{
|
||||
RecallFromCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecallFromCache()
|
||||
{
|
||||
var cached = _commandProviderCache?.Recall(ProviderId);
|
||||
if (cached is not null)
|
||||
{
|
||||
DisplayName = cached.DisplayName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DisplayName))
|
||||
{
|
||||
DisplayName = Extension?.PackageDisplayName ?? Extension?.PackageFamilyName ?? ProviderId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +211,7 @@ public sealed class CommandProviderWrapper
|
||||
var settings = serviceProvider.GetService<SettingsModel>()!;
|
||||
var providerSettings = GetProviderSettings(settings);
|
||||
|
||||
Func<ICommandItem?, bool, TopLevelViewModel> makeAndAdd = (ICommandItem? i, bool fallback) =>
|
||||
var makeAndAdd = (ICommandItem? i, bool fallback) =>
|
||||
{
|
||||
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
|
||||
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i);
|
||||
|
||||
@@ -19,7 +19,7 @@ public partial class OpenSettingsCommand : InvokableCommand
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,4 @@
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Messages;
|
||||
|
||||
public record OpenSettingsMessage()
|
||||
{
|
||||
}
|
||||
public record OpenSettingsMessage(string SettingsPageTag = "");
|
||||
|
||||
@@ -14,11 +14,13 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class ProviderSettingsViewModel : ObservableObject
|
||||
{
|
||||
private static readonly IconInfoViewModel EmptyIcon = new(null);
|
||||
|
||||
private readonly CommandProviderWrapper _provider;
|
||||
private readonly ProviderSettings _providerSettings;
|
||||
private readonly SettingsModel _settings;
|
||||
|
||||
private readonly Lock _initializeSettingsLock = new();
|
||||
|
||||
private Task? _initializeSettingsTask;
|
||||
|
||||
public ProviderSettingsViewModel(
|
||||
@@ -43,7 +45,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
HasFallbackCommands ?
|
||||
$"{ExtensionName}, {TopLevelCommands.Count} commands, {_provider.FallbackItems?.Length} fallback commands" :
|
||||
$"{ExtensionName}, {TopLevelCommands.Count} commands" :
|
||||
Resources.builtin_disabled_extension;
|
||||
$"{ExtensionName}, {Resources.builtin_disabled_extension}";
|
||||
|
||||
[MemberNotNullWhen(true, nameof(Extension))]
|
||||
public bool IsFromExtension => _provider.Extension is not null;
|
||||
@@ -52,7 +54,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
|
||||
public string ExtensionVersion => IsFromExtension ? $"{Extension.Version.Major}.{Extension.Version.Minor}.{Extension.Version.Build}.{Extension.Version.Revision}" : string.Empty;
|
||||
|
||||
public IconInfoViewModel Icon => _provider.Icon;
|
||||
public IconInfoViewModel Icon => IsEnabled ? _provider.Icon : EmptyIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool LoadingSettings { get; set; }
|
||||
@@ -69,6 +71,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
||||
OnPropertyChanged(nameof(IsEnabled));
|
||||
OnPropertyChanged(nameof(ExtensionSubtext));
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
}
|
||||
|
||||
if (value == true)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
internal sealed class CommandProviderCacheContainer
|
||||
{
|
||||
public Dictionary<string, CommandProviderCacheItem> Cache { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public record CommandProviderCacheItem(string DisplayName);
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
[JsonSerializable(typeof(CommandProviderCacheItem))]
|
||||
[JsonSerializable(typeof(Dictionary<string, CommandProviderCacheItem>))]
|
||||
[JsonSerializable(typeof(CommandProviderCacheContainer))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = false)]
|
||||
internal sealed partial class CommandProviderCacheSerializationContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public sealed partial class DefaultCommandProviderCache : ICommandProviderCache, IDisposable
|
||||
{
|
||||
private const string CacheFileName = "commandProviderCache.json";
|
||||
|
||||
private readonly Dictionary<string, CommandProviderCacheItem> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
|
||||
private readonly SupersedingAsyncGate _saveGate;
|
||||
|
||||
public DefaultCommandProviderCache()
|
||||
{
|
||||
_saveGate = new SupersedingAsyncGate(async _ => await TrySaveAsync().ConfigureAwait(false));
|
||||
TryLoad();
|
||||
}
|
||||
|
||||
public void Memorize(string providerId, CommandProviderCacheItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerId);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_cache[providerId] = item;
|
||||
}
|
||||
|
||||
_ = _saveGate.ExecuteAsync();
|
||||
}
|
||||
|
||||
public CommandProviderCacheItem? Recall(string providerId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerId);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_cache.TryGetValue(providerId, out var item);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetCacheFilePath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, CacheFileName);
|
||||
}
|
||||
|
||||
private void TryLoad()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = GetCacheFilePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var loaded = JsonSerializer.Deserialize(
|
||||
json,
|
||||
CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!);
|
||||
if (loaded?.Cache is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cache.Clear();
|
||||
foreach (var kvp in loaded.Cache)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(kvp.Key) && kvp.Value is not null)
|
||||
{
|
||||
_cache[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to load command provider cache: ", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TrySaveAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Dictionary<string, CommandProviderCacheItem> snapshot;
|
||||
lock (_sync)
|
||||
{
|
||||
snapshot = new Dictionary<string, CommandProviderCacheItem>(_cache, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var container = new CommandProviderCacheContainer
|
||||
{
|
||||
Cache = snapshot,
|
||||
};
|
||||
|
||||
var path = GetCacheFilePath();
|
||||
var json = JsonSerializer.Serialize(container, CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!);
|
||||
await File.WriteAllTextAsync(path, json).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to save command provider cache: ", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_saveGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public interface ICommandProviderCache
|
||||
{
|
||||
void Memorize(string providerId, CommandProviderCacheItem item);
|
||||
|
||||
CommandProviderCacheItem? Recall(string providerId);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -25,6 +26,7 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
IDisposable
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ICommandProviderCache _commandProviderCache;
|
||||
private readonly TaskScheduler _taskScheduler;
|
||||
|
||||
private readonly List<CommandProviderWrapper> _builtInCommands = [];
|
||||
@@ -34,9 +36,10 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
|
||||
TaskScheduler IPageContext.Scheduler => _taskScheduler;
|
||||
|
||||
public TopLevelCommandManager(IServiceProvider serviceProvider)
|
||||
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_commandProviderCache = commandProviderCache;
|
||||
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
|
||||
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
|
||||
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
|
||||
@@ -319,7 +322,7 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
try
|
||||
{
|
||||
await extension.StartExtensionAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
return new CommandProviderWrapper(extension, _taskScheduler);
|
||||
return new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace Microsoft.CmdPal.UI;
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
public partial class App : Application, IDisposable
|
||||
{
|
||||
private readonly GlobalErrorHandler _globalErrorHandler = new();
|
||||
|
||||
@@ -67,7 +67,7 @@ public partial class App : Application
|
||||
public App()
|
||||
{
|
||||
#if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER
|
||||
_globalErrorHandler.Register(this);
|
||||
_globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default);
|
||||
#endif
|
||||
|
||||
Services = ConfigureServices();
|
||||
@@ -178,6 +178,7 @@ public partial class App : Application
|
||||
services.AddSingleton(state);
|
||||
|
||||
// Services
|
||||
services.AddSingleton<ICommandProviderCache, DefaultCommandProviderCache>();
|
||||
services.AddSingleton<TopLevelCommandManager>();
|
||||
services.AddSingleton<AliasManager>();
|
||||
services.AddSingleton<HotkeyManager>();
|
||||
@@ -203,4 +204,11 @@ public partial class App : Application
|
||||
services.AddSingleton<ShellViewModel>();
|
||||
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_globalErrorHandler.Dispose();
|
||||
EtwTrace.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -44,13 +44,14 @@ public sealed partial class CommandBar : UserControl,
|
||||
|
||||
public void Receive(OpenContextMenuMessage message)
|
||||
{
|
||||
if (!ViewModel.ShouldShowContextMenu)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.Element is null)
|
||||
{
|
||||
// This is invoked from the "More" button on the command bar
|
||||
if (!ViewModel.ShouldShowContextMenu)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = DispatcherQueue.TryEnqueue(
|
||||
() =>
|
||||
{
|
||||
@@ -65,6 +66,7 @@ public sealed partial class CommandBar : UserControl,
|
||||
}
|
||||
else
|
||||
{
|
||||
// This is invoked from a specific element
|
||||
_ = DispatcherQueue.TryEnqueue(
|
||||
() =>
|
||||
{
|
||||
@@ -126,7 +128,7 @@ public sealed partial class CommandBar : UserControl,
|
||||
|
||||
private void SettingsIcon_Clicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
}
|
||||
|
||||
private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e)
|
||||
|
||||
@@ -203,6 +203,12 @@
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- More section -->
|
||||
<TextBlock Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" Text="More" />
|
||||
<Border>
|
||||
<Button Command="{x:Bind ViewModel.OpenInternalToolsCommand}" Content="Open internal tools" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
@@ -15,14 +15,22 @@ namespace Microsoft.CmdPal.UI.Helpers;
|
||||
/// <summary>
|
||||
/// Global error handler for Command Palette.
|
||||
/// </summary>
|
||||
internal sealed partial class GlobalErrorHandler
|
||||
internal sealed partial class GlobalErrorHandler : IDisposable
|
||||
{
|
||||
private readonly ErrorReportBuilder _errorReportBuilder = new();
|
||||
private Options? _options;
|
||||
private App? _app;
|
||||
|
||||
// GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available.
|
||||
internal void Register(App app)
|
||||
internal void Register(App app, Options options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
app.UnhandledException += App_UnhandledException;
|
||||
_options = options;
|
||||
|
||||
_app = app;
|
||||
_app.UnhandledException += App_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
}
|
||||
@@ -54,21 +62,15 @@ internal sealed partial class GlobalErrorHandler
|
||||
HandleException(e.Exception, Context.UnobservedTaskException);
|
||||
}
|
||||
|
||||
private static void HandleException(Exception ex, Context context)
|
||||
private void HandleException(Exception ex, Context context)
|
||||
{
|
||||
Logger.LogError($"Unhandled exception detected ({context})", ex);
|
||||
|
||||
if (context == Context.MainThreadException)
|
||||
{
|
||||
var error = DiagnosticsHelper.BuildExceptionMessage(ex, null);
|
||||
var report = $"""
|
||||
This is an error report generated by Windows Command Palette.
|
||||
If you are seeing this message, it means the application has encountered an unexpected issue.
|
||||
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
{error}
|
||||
""";
|
||||
var report = _errorReportBuilder.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
|
||||
|
||||
StoreReport(report, storeOnDesktop: false);
|
||||
StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true);
|
||||
|
||||
string message;
|
||||
string caption;
|
||||
@@ -138,6 +140,13 @@ internal sealed partial class GlobalErrorHandler
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_app?.UnhandledException -= App_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException;
|
||||
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
|
||||
}
|
||||
|
||||
private enum Context
|
||||
{
|
||||
Unknown = 0,
|
||||
@@ -146,4 +155,26 @@ internal sealed partial class GlobalErrorHandler
|
||||
UnobservedTaskException,
|
||||
AppDomainUnhandledException,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options controlling how <see cref="GlobalErrorHandler"/> reacts to exceptions
|
||||
/// (what to log, what to show to the user, and where to store reports).
|
||||
/// </summary>
|
||||
internal sealed record Options
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default configuration.
|
||||
/// </summary>
|
||||
public static Options Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports.
|
||||
/// </summary>
|
||||
public bool RedactPii { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory.
|
||||
/// </summary>
|
||||
public bool StoreReportOnUserDesktop { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ internal sealed partial class TrayIconService
|
||||
{
|
||||
if (wParam == PInvoke.WM_USER + 1)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
}
|
||||
else if (wParam == PInvoke.WM_USER + 2)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Windows.Graphics;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Graphics.Gdi;
|
||||
using Windows.Win32.UI.HiDpi;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal static class WindowPositionHelper
|
||||
{
|
||||
private const int DefaultWidth = 800;
|
||||
private const int DefaultHeight = 480;
|
||||
private const int MinimumVisibleSize = 100;
|
||||
private const int DefaultDpi = 96;
|
||||
|
||||
public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi)
|
||||
{
|
||||
if (displayArea is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetDpi = GetDpiForDisplay(displayArea);
|
||||
var predictedSize = ScaleSize(windowSize, windowDpi, targetDpi);
|
||||
|
||||
// Clamp to work area
|
||||
var width = Math.Min(predictedSize.Width, workArea.Width);
|
||||
var height = Math.Min(predictedSize.Height, workArea.Height);
|
||||
|
||||
return new PointInt32(
|
||||
workArea.X + ((workArea.Width - width) / 2),
|
||||
workArea.Y + ((workArea.Height - height) / 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts a saved window rect to ensure it's visible on the nearest display,
|
||||
/// accounting for DPI changes and work area differences.
|
||||
/// </summary>
|
||||
///
|
||||
public static RectInt32 AdjustRectForVisibility(RectInt32 savedRect, SizeInt32 savedScreenSize, int savedDpi)
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest);
|
||||
if (displayArea is null)
|
||||
{
|
||||
return savedRect;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
return savedRect;
|
||||
}
|
||||
|
||||
var targetDpi = GetDpiForDisplay(displayArea);
|
||||
if (savedDpi <= 0)
|
||||
{
|
||||
savedDpi = targetDpi;
|
||||
}
|
||||
|
||||
var hasInvalidSize = savedRect.Width <= 0 || savedRect.Height <= 0;
|
||||
if (hasInvalidSize)
|
||||
{
|
||||
savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight };
|
||||
}
|
||||
|
||||
if (targetDpi != savedDpi)
|
||||
{
|
||||
savedRect = ScaleRect(savedRect, savedDpi, targetDpi);
|
||||
}
|
||||
|
||||
var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea);
|
||||
|
||||
var shouldRecenter = hasInvalidSize ||
|
||||
IsOffscreen(savedRect, workArea) ||
|
||||
savedScreenSize.Width != workArea.Width ||
|
||||
savedScreenSize.Height != workArea.Height;
|
||||
|
||||
if (shouldRecenter)
|
||||
{
|
||||
return CenterRectInWorkArea(clampedSize, workArea);
|
||||
}
|
||||
|
||||
return new RectInt32(savedRect.X, savedRect.Y, clampedSize.Width, clampedSize.Height);
|
||||
}
|
||||
|
||||
private static int GetDpiForDisplay(DisplayArea displayArea)
|
||||
{
|
||||
var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
|
||||
if (hMonitor == IntPtr.Zero)
|
||||
{
|
||||
return DefaultDpi;
|
||||
}
|
||||
|
||||
var hr = PInvoke.GetDpiForMonitor(
|
||||
new HMONITOR(hMonitor),
|
||||
MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI,
|
||||
out var dpiX,
|
||||
out _);
|
||||
|
||||
return hr.Succeeded && dpiX > 0 ? (int)dpiX : DefaultDpi;
|
||||
}
|
||||
|
||||
private static SizeInt32 ScaleSize(SizeInt32 size, int fromDpi, int toDpi)
|
||||
{
|
||||
if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi)
|
||||
{
|
||||
return size;
|
||||
}
|
||||
|
||||
var scale = (double)toDpi / fromDpi;
|
||||
return new SizeInt32(
|
||||
(int)Math.Round(size.Width * scale),
|
||||
(int)Math.Round(size.Height * scale));
|
||||
}
|
||||
|
||||
private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi)
|
||||
{
|
||||
var scale = (double)toDpi / fromDpi;
|
||||
return new RectInt32(
|
||||
(int)Math.Round(rect.X * scale),
|
||||
(int)Math.Round(rect.Y * scale),
|
||||
(int)Math.Round(rect.Width * scale),
|
||||
(int)Math.Round(rect.Height * scale));
|
||||
}
|
||||
|
||||
private static SizeInt32 ClampSize(int width, int height, RectInt32 workArea) =>
|
||||
new(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height));
|
||||
|
||||
private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) =>
|
||||
new(
|
||||
workArea.X + ((workArea.Width - size.Width) / 2),
|
||||
workArea.Y + ((workArea.Height - size.Height) / 2),
|
||||
size.Width,
|
||||
size.Height);
|
||||
|
||||
private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) =>
|
||||
rect.X + MinimumVisibleSize > workArea.X + workArea.Width ||
|
||||
rect.X + rect.Width - MinimumVisibleSize < workArea.X ||
|
||||
rect.Y + MinimumVisibleSize > workArea.Y + workArea.Height ||
|
||||
rect.Y + rect.Height - MinimumVisibleSize < workArea.Y;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Input;
|
||||
@@ -32,13 +31,9 @@ using Windows.ApplicationModel.Activation;
|
||||
using Windows.Foundation;
|
||||
using Windows.Graphics;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
using Windows.UI.WindowManagement;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.Graphics.Dwm;
|
||||
using Windows.Win32.Graphics.Gdi;
|
||||
using Windows.Win32.UI.HiDpi;
|
||||
using Windows.Win32.UI.Input.KeyboardAndMouse;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
using WinRT;
|
||||
@@ -60,9 +55,6 @@ public sealed partial class MainWindow : WindowEx,
|
||||
IRecipient<DragCompletedMessage>,
|
||||
IDisposable
|
||||
{
|
||||
private const int DefaultWidth = 800;
|
||||
private const int DefaultHeight = 480;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")]
|
||||
private readonly uint WM_TASKBAR_RESTART;
|
||||
@@ -226,39 +218,40 @@ public sealed partial class MainWindow : WindowEx,
|
||||
PositionCentered(displayArea);
|
||||
}
|
||||
|
||||
private void PositionCentered(DisplayArea displayArea)
|
||||
{
|
||||
var position = WindowPositionHelper.CalculateCenteredPosition(
|
||||
displayArea,
|
||||
AppWindow.Size,
|
||||
(int)this.GetDpiForWindow());
|
||||
|
||||
if (position is not null)
|
||||
{
|
||||
// Use Move(), not MoveAndResize(). Windows auto-resizes on DPI change via WM_DPICHANGED;
|
||||
// the helper already accounts for this when calculating the centered position.
|
||||
AppWindow.Move((PointInt32)position);
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreWindowPosition()
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>();
|
||||
if (settings?.LastWindowPosition is not WindowPosition savedPosition)
|
||||
if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition)
|
||||
{
|
||||
PositionCentered();
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedPosition.Width <= 0 || savedPosition.Height <= 0)
|
||||
{
|
||||
PositionCentered();
|
||||
return;
|
||||
}
|
||||
// MoveAndResize is safe here—we're restoring a saved state at startup,
|
||||
// not moving a live window between displays.
|
||||
var newRect = WindowPositionHelper.AdjustRectForVisibility(
|
||||
savedPosition.ToPhysicalWindowRectangle(),
|
||||
new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight),
|
||||
savedPosition.Dpi);
|
||||
|
||||
var newRect = EnsureWindowIsVisible(savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi);
|
||||
AppWindow.MoveAndResize(newRect);
|
||||
}
|
||||
|
||||
private void PositionCentered(DisplayArea displayArea)
|
||||
{
|
||||
if (displayArea is not null)
|
||||
{
|
||||
var centeredPosition = AppWindow.Position;
|
||||
centeredPosition.X = (displayArea.WorkArea.Width - AppWindow.Size.Width) / 2;
|
||||
centeredPosition.Y = (displayArea.WorkArea.Height - AppWindow.Size.Height) / 2;
|
||||
|
||||
centeredPosition.X += displayArea.WorkArea.X;
|
||||
centeredPosition.Y += displayArea.WorkArea.Y;
|
||||
AppWindow.Move(centeredPosition);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateWindowPositionInMemory()
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary;
|
||||
@@ -352,7 +345,8 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
if (target == MonitorBehavior.ToLast)
|
||||
{
|
||||
var newRect = EnsureWindowIsVisible(_currentWindowPosition.ToPhysicalWindowRectangle(), new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight), _currentWindowPosition.Dpi);
|
||||
var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight);
|
||||
var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi);
|
||||
AppWindow.MoveAndResize(newRect);
|
||||
}
|
||||
else
|
||||
@@ -382,115 +376,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the window rectangle is visible on-screen.
|
||||
/// </summary>
|
||||
/// <param name="windowRect">The window rectangle in physical pixels.</param>
|
||||
/// <param name="originalScreen">The desktop area the window was positioned on.</param>
|
||||
/// <param name="originalDpi">The window's original DPI.</param>
|
||||
/// <returns>
|
||||
/// A window rectangle in physical pixels, moved to the nearest display and resized
|
||||
/// if the DPI has changed.
|
||||
/// </returns>
|
||||
private static RectInt32 EnsureWindowIsVisible(RectInt32 windowRect, SizeInt32 originalScreen, int originalDpi)
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromRect(windowRect, DisplayAreaFallback.Nearest);
|
||||
if (displayArea is null)
|
||||
{
|
||||
return windowRect;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
// Fallback, nothing reasonable to do
|
||||
return windowRect;
|
||||
}
|
||||
|
||||
var effectiveDpi = GetEffectiveDpiFromDisplayId(displayArea);
|
||||
if (originalDpi <= 0)
|
||||
{
|
||||
originalDpi = effectiveDpi; // use current DPI as baseline (no scaling adjustment needed)
|
||||
}
|
||||
|
||||
var hasInvalidSize = windowRect.Width <= 0 || windowRect.Height <= 0;
|
||||
if (hasInvalidSize)
|
||||
{
|
||||
windowRect = new RectInt32(windowRect.X, windowRect.Y, DefaultWidth, DefaultHeight);
|
||||
}
|
||||
|
||||
// If we have a DPI change, scale the window rectangle accordingly
|
||||
if (effectiveDpi != originalDpi)
|
||||
{
|
||||
var scalingFactor = effectiveDpi / (double)originalDpi;
|
||||
windowRect = new RectInt32(
|
||||
(int)Math.Round(windowRect.X * scalingFactor),
|
||||
(int)Math.Round(windowRect.Y * scalingFactor),
|
||||
(int)Math.Round(windowRect.Width * scalingFactor),
|
||||
(int)Math.Round(windowRect.Height * scalingFactor));
|
||||
}
|
||||
|
||||
var targetWidth = Math.Min(windowRect.Width, workArea.Width);
|
||||
var targetHeight = Math.Min(windowRect.Height, workArea.Height);
|
||||
|
||||
// Ensure at least some minimum visible area (e.g., 100 pixels)
|
||||
// This helps prevent the window from being entirely offscreen, regardless of display scaling.
|
||||
const int minimumVisibleSize = 100;
|
||||
var isOffscreen =
|
||||
windowRect.X + minimumVisibleSize > workArea.X + workArea.Width ||
|
||||
windowRect.X + windowRect.Width - minimumVisibleSize < workArea.X ||
|
||||
windowRect.Y + minimumVisibleSize > workArea.Y + workArea.Height ||
|
||||
windowRect.Y + windowRect.Height - minimumVisibleSize < workArea.Y;
|
||||
|
||||
// if the work area size has changed, re-center the window
|
||||
var workAreaSizeChanged =
|
||||
originalScreen.Width != workArea.Width ||
|
||||
originalScreen.Height != workArea.Height;
|
||||
|
||||
int targetX;
|
||||
int targetY;
|
||||
var recenter = isOffscreen || workAreaSizeChanged || hasInvalidSize;
|
||||
if (recenter)
|
||||
{
|
||||
targetX = workArea.X + ((workArea.Width - targetWidth) / 2);
|
||||
targetY = workArea.Y + ((workArea.Height - targetHeight) / 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetX = windowRect.X;
|
||||
targetY = windowRect.Y;
|
||||
}
|
||||
|
||||
return new RectInt32(targetX, targetY, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
private static int GetEffectiveDpiFromDisplayId(DisplayArea displayArea)
|
||||
{
|
||||
var effectiveDpi = 96;
|
||||
|
||||
var hMonitor = (HMONITOR)Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
|
||||
if (!hMonitor.IsNull)
|
||||
{
|
||||
var hr = PInvoke.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _);
|
||||
if (hr == 0)
|
||||
{
|
||||
effectiveDpi = (int)dpiX;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning($"GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {displayArea.DisplayId}");
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveDpi <= 0)
|
||||
{
|
||||
effectiveDpi = 96;
|
||||
}
|
||||
|
||||
return effectiveDpi;
|
||||
}
|
||||
|
||||
private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
||||
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
||||
{
|
||||
// Leaving a note here, in case we ever need it:
|
||||
// https://github.com/microsoft/microsoft-ui-xaml/issues/6454
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<None Remove="Pages\Settings\GeneralPage.xaml" />
|
||||
<None Remove="SettingsWindow.xaml" />
|
||||
<None Remove="Settings\AppearancePage.xaml" />
|
||||
<None Remove="Settings\InternalPage.xaml" />
|
||||
<None Remove="ShellPage.xaml" />
|
||||
<None Remove="Styles\Colors.xaml" />
|
||||
<None Remove="Styles\Settings.xaml" />
|
||||
@@ -264,6 +265,11 @@
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Settings\InternalPage.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Styles\Colors.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
|
||||
@@ -257,11 +257,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
{
|
||||
_ = DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
OpenSettings();
|
||||
OpenSettings(message.SettingsPageTag);
|
||||
});
|
||||
}
|
||||
|
||||
public void OpenSettings()
|
||||
public void OpenSettings(string pageTag)
|
||||
{
|
||||
if (_settingsWindow is null)
|
||||
{
|
||||
@@ -270,6 +270,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
_settingsWindow.Activate();
|
||||
_settingsWindow.BringToFront();
|
||||
_settingsWindow.Navigate(pageTag);
|
||||
}
|
||||
|
||||
public void Receive(ShowDetailsMessage message)
|
||||
|
||||
@@ -229,12 +229,26 @@
|
||||
<controls:SettingsCard.HeaderIcon>
|
||||
<cpcontrols:ContentIcon>
|
||||
<cpcontrols:ContentIcon.Content>
|
||||
<cpcontrols:IconBox
|
||||
Width="20"
|
||||
Height="20"
|
||||
<controls:SwitchPresenter
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
TargetType="x:Boolean"
|
||||
Value="{x:Bind Icon.IsSet, FallbackValue=x:False, Mode=OneWay}">
|
||||
<controls:Case Value="True">
|
||||
<cpcontrols:IconBox
|
||||
Width="20"
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
</controls:Case>
|
||||
<controls:Case Value="False">
|
||||
<Image
|
||||
Width="20"
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Source="ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png" />
|
||||
</controls:Case>
|
||||
</controls:SwitchPresenter>
|
||||
</cpcontrols:ContentIcon.Content>
|
||||
</cpcontrols:ContentIcon>
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Settings;
|
||||
|
||||
public partial class InternalPage
|
||||
{
|
||||
internal static class SampleData
|
||||
{
|
||||
internal static string ExceptionMessageWithPii { get; } =
|
||||
$"""
|
||||
Test exception with personal information; thrown from the UI thread
|
||||
|
||||
Here is e-mail address <jane.doe@contoso.com>
|
||||
IPv4 address: 192.168.100.1
|
||||
IPv4 loopback address: 127.0.0.1
|
||||
MAC address: 00-14-22-01-23-45
|
||||
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
|
||||
IPv6 loopback address: ::1
|
||||
Password: P@ssw0rd123!
|
||||
Password=secret
|
||||
Api key: 1234567890abcdef
|
||||
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
|
||||
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
|
||||
X-API-key: 1234567890abcdef
|
||||
Pet-Shop-Subscription-Key: 1234567890abcdef
|
||||
Here is a user name {Environment.UserName}
|
||||
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\Pictures
|
||||
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
|
||||
Here is machine name {Environment.MachineName}
|
||||
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
|
||||
User email john.doe@company.com failed validation
|
||||
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
|
||||
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
|
||||
Phone number 555-123-4567 is invalid
|
||||
API key abc123def456ghi789jkl012mno345pqr678 expired
|
||||
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
|
||||
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
|
||||
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
|
||||
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
|
||||
Email service error: mailto:admin@internal-company.com?subject=Alert
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Page
|
||||
x:Class="Microsoft.CmdPal.UI.Settings.InternalPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<Grid Padding="16">
|
||||
<StackPanel
|
||||
MaxWidth="1000"
|
||||
HorizontalAlignment="Stretch"
|
||||
Spacing="{StaticResource SettingsCardSpacing}">
|
||||
|
||||
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="Tools on this page are for internal use only. This page is not visible in CI builds." />
|
||||
|
||||
<!-- Exception Handling Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Exception Handling" />
|
||||
<controls:SettingsExpander
|
||||
Description="Actions for testing global exception handling from the application"
|
||||
Header="Throw exceptions"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:SettingsExpander.Items>
|
||||
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread">
|
||||
<Button Click="ThrowPlainMainThreadException_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread (with PII)">
|
||||
<Button Click="ThrowPlainMainThreadExceptionPii_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard Description="Throw with delay, when the task is collected by the GC" Header="Throw unobserved exception from a task">
|
||||
<Button Click="ThrowExceptionInUnobservedTask_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
</controls:SettingsExpander.Items>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<!-- Diagnostics Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Diagnostics" />
|
||||
<controls:SettingsCard
|
||||
x:Name="LogsSettingsCard"
|
||||
Header="Logs folder"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenLogsCardClicked" Content="Open folder" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard
|
||||
x:Name="CurrentLogFileSettingsCard"
|
||||
Header="Current log file"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenCurrentLogCardClicked" Content="Open log" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<!-- Data Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Data and Files" />
|
||||
<controls:SettingsCard
|
||||
x:Name="ConfigurationFolderSettingsCard"
|
||||
Header="Configuration folder"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenConfigFolderCardClick" Content="Open folder" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Page>
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Windows.System;
|
||||
using Page = Microsoft.UI.Xaml.Controls.Page;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// An empty page that can be used on its own or navigated to within a Frame.
|
||||
/// </summary>
|
||||
public sealed partial class InternalPage : Page
|
||||
{
|
||||
public InternalPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void ThrowPlainMainThreadException_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from the UI thread");
|
||||
throw new NotImplementedException("Test exception; thrown from the UI thread");
|
||||
}
|
||||
|
||||
private void ThrowExceptionInUnobservedTask_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Starting a task that will throw test exception");
|
||||
Task.Run(() =>
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from a task");
|
||||
throw new InvalidOperationException("Test exception; thrown from a task");
|
||||
});
|
||||
}
|
||||
|
||||
private void ThrowPlainMainThreadExceptionPii_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from the UI thread (PII)");
|
||||
throw new InvalidOperationException(SampleData.ExceptionMessageWithPii);
|
||||
}
|
||||
|
||||
private async void OpenLogsCardClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFolderPath = Logger.CurrentVersionLogDirectoryPath;
|
||||
if (Directory.Exists(logFolderPath))
|
||||
{
|
||||
await Launcher.LaunchFolderPathAsync(logFolderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open directory in Explorer", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenCurrentLogCardClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logPath = Logger.CurrentLogFile;
|
||||
if (File.Exists(logPath))
|
||||
{
|
||||
await Launcher.LaunchUriAsync(new Uri(logPath));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open log file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenConfigFolderCardClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
await Launcher.LaunchFolderPathAsync(directory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open directory in Explorer", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions"
|
||||
Icon="{ui:FontIcon Glyph=}"
|
||||
Tag="Extensions" />
|
||||
<!-- "Internal Tools" page item is added dynamically from code -->
|
||||
</NavigationView.MenuItems>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
|
||||
@@ -30,6 +30,8 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
{
|
||||
private readonly LocalKeyboardListener _localKeyboardListener;
|
||||
|
||||
private readonly NavigationViewItem? _internalNavItem;
|
||||
|
||||
public ObservableCollection<Crumb> BreadCrumbs { get; } = [];
|
||||
|
||||
// Gets or sets optional action invoked after NavigationView is loaded.
|
||||
@@ -54,6 +56,23 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
_localKeyboardListener.Start();
|
||||
Closed += SettingsWindow_Closed;
|
||||
RootElement.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(RootElement_OnPointerPressed), true);
|
||||
|
||||
if (!BuildInfo.IsCiBuild)
|
||||
{
|
||||
_internalNavItem = new NavigationViewItem
|
||||
{
|
||||
Content = "Internal Tools",
|
||||
Icon = new FontIcon { Glyph = "\uEC7A" },
|
||||
Tag = "Internal",
|
||||
};
|
||||
NavView.MenuItems.Add(_internalNavItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
_internalNavItem = null;
|
||||
}
|
||||
|
||||
Navigate("General");
|
||||
}
|
||||
|
||||
private void SettingsWindow_Closed(object sender, WindowEventArgs args)
|
||||
@@ -68,9 +87,6 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
// Delay necessary to ensure NavigationView visual state can match navigation
|
||||
Task.Delay(500).ContinueWith(_ => this.NavigationViewLoaded?.Invoke(), TaskScheduler.FromCurrentSynchronizationContext());
|
||||
|
||||
NavView.SelectedItem = NavView.MenuItems[0];
|
||||
Navigate("General");
|
||||
|
||||
if (sender is NavigationView navigationView)
|
||||
{
|
||||
// Register for pane open/close changes to announce to screen readers
|
||||
@@ -96,15 +112,33 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
Navigate((selectedItem.Tag as string)!);
|
||||
}
|
||||
|
||||
private void Navigate(string page)
|
||||
internal void Navigate(string page)
|
||||
{
|
||||
var pageType = page switch
|
||||
Type? pageType;
|
||||
switch (page)
|
||||
{
|
||||
"General" => typeof(GeneralPage),
|
||||
"Appearance" => typeof(AppearancePage),
|
||||
"Extensions" => typeof(ExtensionsPage),
|
||||
_ => null,
|
||||
};
|
||||
case "General":
|
||||
pageType = typeof(GeneralPage);
|
||||
break;
|
||||
case "Appearance":
|
||||
pageType = typeof(AppearancePage);
|
||||
break;
|
||||
case "Extensions":
|
||||
pageType = typeof(ExtensionsPage);
|
||||
break;
|
||||
case "Internal":
|
||||
pageType = typeof(InternalPage);
|
||||
break;
|
||||
case "":
|
||||
// intentional no-op: empty tag means no navigation
|
||||
pageType = null;
|
||||
break;
|
||||
default:
|
||||
// unknown page, no-op and log
|
||||
pageType = null;
|
||||
Logger.LogError($"Unknown settings page tag '{page}'");
|
||||
break;
|
||||
}
|
||||
|
||||
if (pageType is not null)
|
||||
{
|
||||
@@ -268,6 +302,12 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
BreadCrumbs.Add(new(extensionsPageType, extensionsPageType));
|
||||
BreadCrumbs.Add(new(vm.DisplayName, vm));
|
||||
}
|
||||
else if (e.SourcePageType == typeof(InternalPage) && _internalNavItem is not null)
|
||||
{
|
||||
NavView.SelectedItem = _internalNavItem;
|
||||
var pageType = "Internal";
|
||||
BreadCrumbs.Add(new(pageType, pageType));
|
||||
}
|
||||
else
|
||||
{
|
||||
BreadCrumbs.Add(new($"[{e.SourcePageType?.Name}]", string.Empty));
|
||||
|
||||
@@ -8,8 +8,10 @@ using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.UI.Helpers;
|
||||
using Microsoft.CmdPal.UI.Messages;
|
||||
using Microsoft.UI;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
@@ -99,6 +101,12 @@ internal sealed partial class DevRibbonViewModel : ObservableObject
|
||||
LatestLogs.Clear();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInternalTools()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Internal"));
|
||||
}
|
||||
|
||||
private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener
|
||||
{
|
||||
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Diagnostics.CodeAnalysis;
|
||||
global using System.Linq;
|
||||
global using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Microsoft.CmdPal.Common.UnitTests</RootNamespace>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class ConnectionStringRuleProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(1, ruleList.Count);
|
||||
Assert.AreEqual("Connection string parameters", ruleList[0].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Server=localhost;Database=mydb;User ID=admin;Password=secret123", "Server=[REDACTED];Database=[REDACTED];User ID=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("Data Source=server.example.com;Initial Catalog=testdb;Uid=user;Pwd=pass", "Data Source=[REDACTED];Initial Catalog=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED]")]
|
||||
[DataRow("Server=localhost;Password=my_secret", "Server=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("No connection string here", "No connection string here")]
|
||||
public void ConnectionStringRules_ShouldMaskConnectionStringParameters(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Password=\"complexPassword123!\"", "Password=[REDACTED]")]
|
||||
[DataRow("Password='myPassword'", "Password=[REDACTED]")]
|
||||
[DataRow("Password=unquotedSecret", "Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleQuotedAndUnquotedValues(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("SERVER=server1;PASSWORD=pass1", "SERVER=[REDACTED];PASSWORD=[REDACTED]")]
|
||||
[DataRow("server=server1;password=pass1", "server=[REDACTED];password=[REDACTED]")]
|
||||
[DataRow("Server=server1;Password=pass1", "Server=[REDACTED];Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldBeCaseInsensitive(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("User ID=admin;Username=john;Password=secret", "User ID=[REDACTED];Username=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("Database=mydb;Uid=user1;Pwd=pass1;Server=localhost", "Database=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED];Server=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleMultipleParameters(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Server = localhost ; Password = secret123", "Server=[REDACTED] ; Password=[REDACTED]")]
|
||||
[DataRow("Initial Catalog=db; User ID=admin; Password=pass", "Initial Catalog=[REDACTED]; User ID=[REDACTED]; Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleWhitespace(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
public partial class ErrorReportSanitizerTests
|
||||
{
|
||||
private static class TestData
|
||||
{
|
||||
internal static string Input =>
|
||||
$"""
|
||||
HRESULT: 0x80004005
|
||||
HRESULT: -2147467259
|
||||
|
||||
Here is e-mail address <jane.doe@contoso.com>
|
||||
IPv4 address: 192.168.100.1
|
||||
IPv4 loopback address: 127.0.0.1
|
||||
MAC address: 00-14-22-01-23-45
|
||||
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
|
||||
IPv6 loopback address: ::1
|
||||
Password: P@ssw0rd123!
|
||||
Password=secret
|
||||
Api key: 1234567890abcdef
|
||||
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
|
||||
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
|
||||
X-API-key: 1234567890abcdef
|
||||
Pet-Shop-Subscription-Key: 1234567890abcdef
|
||||
Here is a user name {Environment.UserName}
|
||||
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\RandomFolder
|
||||
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
|
||||
Here is machine name {Environment.MachineName}
|
||||
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
|
||||
User email john.doe@company.com failed validation
|
||||
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
|
||||
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
|
||||
Phone number 555-123-4567 is invalid
|
||||
API key abc123def456ghi789jkl012mno345pqr678 expired
|
||||
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
|
||||
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
|
||||
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
|
||||
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
|
||||
Email service error: mailto:admin@internal-company.com?subject=Alert
|
||||
""";
|
||||
|
||||
public const string Expected =
|
||||
$"""
|
||||
HRESULT: 0x80004005
|
||||
HRESULT: -2147467259
|
||||
|
||||
Here is e-mail address <[EMAIL_REDACTED]>
|
||||
IPv4 address: [IP4_REDACTED]
|
||||
IPv4 loopback address: [IP4_REDACTED]
|
||||
MAC address: [MAC_ADDRESS_REDACTED]
|
||||
IPv6 address: [IP6_REDACTED]
|
||||
IPv6 loopback address: [IP6_REDACTED]
|
||||
Password: [REDACTED]
|
||||
Password= [REDACTED]
|
||||
Api key: [REDACTED]
|
||||
PostgreSQL connection string: [REDACTED]
|
||||
InstrumentationKey= [REDACTED]
|
||||
X-API-key: [REDACTED]
|
||||
Pet-Shop-Subscription-Key: [REDACTED]
|
||||
Here is a user name [USERNAME_REDACTED]
|
||||
And here is a profile path [USER_PROFILE_DIR]RandomFolder
|
||||
Here is a local app data path [LOCALAPPLICATIONDATA_DIR]Microsoft\PowerToys\CmdPal
|
||||
Here is machine name [MACHINE_NAME_REDACTED]
|
||||
JWT token: [REDACTED]
|
||||
User email [EMAIL_REDACTED] failed validation
|
||||
File not found: [MYDOCUMENTS_DIR]se****.txt
|
||||
Connection string: [REDACTED] ID=[REDACTED];Password= [REDACTED]
|
||||
Phone number [PHONE_REDACTED] is invalid
|
||||
API key [TOKEN_REDACTED] expired
|
||||
Failed to connect to [URL_REDACTED]
|
||||
Error accessing [URL_REDACTED]
|
||||
JDBC connection failed: [URL_REDACTED]
|
||||
FTP upload error: [URL_REDACTED]
|
||||
Email service error: mailto:[EMAIL_REDACTED]?subject=Alert
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public partial class ErrorReportSanitizerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Sanitize_ShouldMaskPiiInErrorReport()
|
||||
{
|
||||
// Arrange
|
||||
var reportSanitizer = new ErrorReportSanitizer();
|
||||
var input = TestData.Input;
|
||||
|
||||
// Act
|
||||
var result = reportSanitizer.Sanitize(input);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(TestData.Expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class PiiRuleProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(4, ruleList.Count);
|
||||
Assert.AreEqual("Email addresses", ruleList[0].Description);
|
||||
Assert.AreEqual("Social Security Numbers", ruleList[1].Description);
|
||||
Assert.AreEqual("Credit card numbers", ruleList[2].Description);
|
||||
Assert.AreEqual("Phone numbers", ruleList[3].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Contact me at john.doe@contoso.com", "Contact me at [EMAIL_REDACTED]")]
|
||||
[DataRow("Contact me at a_b-c%2@foo-bar.example.co.uk", "Contact me at [EMAIL_REDACTED]")]
|
||||
[DataRow("My email is john@sub-domain.contoso.com.", "My email is [EMAIL_REDACTED].")]
|
||||
[DataRow("Two: a@b.com and c@d.org", "Two: [EMAIL_REDACTED] and [EMAIL_REDACTED]")]
|
||||
[DataRow("No email here", "No email here")]
|
||||
public void EmailRules_ShouldMaskEmailAddresses(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Call me at 123-456-7890", "Call me at [PHONE_REDACTED]")]
|
||||
[DataRow("My number is (123) 456-7890.", "My number is [PHONE_REDACTED].")]
|
||||
[DataRow("Office: +1 123 456 7890", "Office: [PHONE_REDACTED]")]
|
||||
[DataRow("Two numbers: 123-456-7890 and +420 777123456", "Two numbers: [PHONE_REDACTED] and [PHONE_REDACTED]")]
|
||||
[DataRow("Czech phone +420 777 123 456", "Czech phone [PHONE_REDACTED]")]
|
||||
[DataRow("Slovak phone +421 777 12 34 56", "Slovak phone [PHONE_REDACTED]")]
|
||||
[DataRow("No phone number here", "No phone number here")]
|
||||
public void PhoneRules_ShouldMaskPhoneNumbers(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("My SSN is 123-45-6789", "My SSN is [SSN_REDACTED]")]
|
||||
[DataRow("No SSN here", "No SSN here")]
|
||||
public void SsnRules_ShouldMaskSsn(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("My credit card number is 1234-5678-9012-3456", "My credit card number is [CARD_REDACTED]")]
|
||||
[DataRow("My credit card number is 1234567890123456", "My credit card number is [CARD_REDACTED]")]
|
||||
[DataRow("No credit card here", "No credit card here")]
|
||||
public void CreditCardRules_ShouldMaskCreditCardNumbers(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Error code: 0x80070005", "Error code: 0x80070005")]
|
||||
[DataRow("Error code: -2147467262", "Error code: -2147467262")]
|
||||
[DataRow("GUID: 123e4567-e89b-12d3-a456-426614174000", "GUID: 123e4567-e89b-12d3-a456-426614174000")]
|
||||
[DataRow("Timestamp: 2023-10-05T14:32:10Z", "Timestamp: 2023-10-05T14:32:10Z")]
|
||||
[DataRow("Version: 1.2.3", "Version: 1.2.3")]
|
||||
[DataRow("Version: 10.0.22631.3448", "Version: 10.0.22631.3448")]
|
||||
[DataRow("MAC: 00:1A:2B:3C:4D:5E", "MAC: 00:1A:2B:3C:4D:5E")]
|
||||
[DataRow("Date: 2023-10-05", "Date: 2023-10-05")]
|
||||
[DataRow("Date: 05/10/2023", "Date: 05/10/2023")]
|
||||
public void PiiRuleProvider_ShouldNotOverRedact(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class SecretKeyValueRulesProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(1, ruleList.Count);
|
||||
Assert.AreEqual("Sensitive key/value pairs", ruleList[0].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=secret123", "password= [REDACTED]")]
|
||||
[DataRow("passphrase=myPassphrase", "passphrase= [REDACTED]")]
|
||||
[DataRow("pwd=test", "pwd= [REDACTED]")]
|
||||
[DataRow("passwd=pass1234", "passwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskPasswordSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("token=abc123def456", "token= [REDACTED]")]
|
||||
[DataRow("access_token=token_value", "access_token= [REDACTED]")]
|
||||
[DataRow("refresh-token=refresh_value", "refresh-token= [REDACTED]")]
|
||||
[DataRow("id token=id_token_value", "id token= [REDACTED]")]
|
||||
[DataRow("bearer token=bearer_value", "bearer token= [REDACTED]")]
|
||||
[DataRow("session token=session_value", "session token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskTokens(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("api key=my_api_key", "api key= [REDACTED]")]
|
||||
[DataRow("api-key=key123", "api-key= [REDACTED]")]
|
||||
[DataRow("api_key=secret_key", "api_key= [REDACTED]")]
|
||||
[DataRow("x-api-key=api123", "x-api-key= [REDACTED]")]
|
||||
[DataRow("x api key=key456", "x api key= [REDACTED]")]
|
||||
[DataRow("client id=client123", "client id= [REDACTED]")]
|
||||
[DataRow("client-secret=secret123", "client-secret= [REDACTED]")]
|
||||
[DataRow("consumer secret=secret456", "consumer secret= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskApiCredentials(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("subscription key=sub_key_123", "subscription key= [REDACTED]")]
|
||||
[DataRow("instrumentation key=instr_key", "instrumentation key= [REDACTED]")]
|
||||
[DataRow("account key=account123", "account key= [REDACTED]")]
|
||||
[DataRow("storage account key=storage_key", "storage account key= [REDACTED]")]
|
||||
[DataRow("shared access key=sak123", "shared access key= [REDACTED]")]
|
||||
[DataRow("SAS token=sas123", "SAS token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskCloudPlatformKeys(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("connection string=Server=localhost;Pwd=pass", "connection string= [REDACTED]")]
|
||||
[DataRow("conn string=conn_value", "conn string= [REDACTED]")]
|
||||
[DataRow("storage connection string=connection_value", "storage connection string= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskConnectionStrings(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("private key=pk123", "private key= [REDACTED]")]
|
||||
[DataRow("certificate password=cert_pass", "certificate password= [REDACTED]")]
|
||||
[DataRow("client certificate password=cert123", "client certificate password= [REDACTED]")]
|
||||
[DataRow("pfx password=pfx_pass", "pfx password= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskCertificateSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("aws access key id=AKIAIOSFODNN7EXAMPLE", "aws access key id= [REDACTED]")]
|
||||
[DataRow("aws secret access key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "aws secret access key= [REDACTED]")]
|
||||
[DataRow("aws session token=session_token_value", "aws session token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskAwsKeys(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=\"complexPassword123!\"", "password= \"[REDACTED]\"")]
|
||||
[DataRow("api-key='secret-key'", "api-key= '[REDACTED]'")]
|
||||
[DataRow("token=\"bearer_token_value\"", "token= \"[REDACTED]\"")]
|
||||
public void SecretKeyValueRules_ShouldPreserveQuotesAroundRedactedValue(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("PASSWORD=secret", "PASSWORD= [REDACTED]")]
|
||||
[DataRow("Api-Key=key123", "Api-Key= [REDACTED]")]
|
||||
[DataRow("CLIENT_ID=client123", "CLIENT_ID= [REDACTED]")]
|
||||
[DataRow("Pwd=pass123", "Pwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldBeCaseInsensitive(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("regularKey=regularValue", "regularKey=regularValue")]
|
||||
[DataRow("config=myConfig", "config=myConfig")]
|
||||
[DataRow("hostname=server.example.com", "hostname=server.example.com")]
|
||||
[DataRow("port=8080", "port=8080")]
|
||||
public void SecretKeyValueRules_ShouldNotRedactNonSecretKeyValuePairs(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password:secret123", "password: [REDACTED]")]
|
||||
[DataRow("api key:api_key_value", "api key: [REDACTED]")]
|
||||
[DataRow("client_secret:secret_value", "client_secret: [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldSupportColonSeparator(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password = secret123", "password= [REDACTED]")]
|
||||
[DataRow("api key = api_key_value", "api key= [REDACTED]")]
|
||||
[DataRow("token : token_value", "token: [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldHandleWhitespace(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=secret API_KEY=key config=myConfig", "password= [REDACTED] API_KEY= [REDACTED] config=myConfig")]
|
||||
[DataRow("client_id=id123 name=admin pwd=pass123", "client_id= [REDACTED] name=admin pwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldHandleMultipleKeyValuePairsInSingleString(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("cosmos db key=cosmos_key", "cosmos db key= [REDACTED]")]
|
||||
[DataRow("service principal secret=sp_secret", "service principal secret= [REDACTED]")]
|
||||
[DataRow("shared access signature=sas_signature", "shared access signature= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskServiceSpecificSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only helpers for applying SanitizationRule sets without relying on production ITextSanitizer implementation.
|
||||
/// </summary>
|
||||
public static class SanitizerTestHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies the provided rules to the input, in order, mimicking the production sanitizer behavior closely
|
||||
/// but without any external dependencies.
|
||||
/// </summary>
|
||||
public static string ApplyRules(string? input, IEnumerable<SanitizationRule> rules)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = input;
|
||||
foreach (var rule in rules ?? [])
|
||||
{
|
||||
try
|
||||
{
|
||||
var previous = result;
|
||||
result = rule.Evaluator is null
|
||||
? rule.Regex.Replace(previous, rule.Replacement ?? string.Empty)
|
||||
: rule.Regex.Replace(previous, rule.Evaluator);
|
||||
|
||||
// Guardrail to avoid accidental mass-redaction from a faulty rule
|
||||
if (result.Length < previous.Length * 0.3)
|
||||
{
|
||||
result = previous;
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// Ignore timeouts in tests
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a lightweight sanitizer instance backed by the given rules.
|
||||
/// Useful when a component expects an ITextSanitizer, but you want deterministic behavior in tests.
|
||||
/// </summary>
|
||||
public static ITextSanitizer CreateSanitizer(IEnumerable<SanitizationRule> rules)
|
||||
=> new InlineSanitizer(rules);
|
||||
|
||||
private sealed class InlineSanitizer : ITextSanitizer
|
||||
{
|
||||
private readonly List<SanitizationRule> _rules;
|
||||
|
||||
public InlineSanitizer(IEnumerable<SanitizationRule> rules)
|
||||
{
|
||||
_rules = rules?.ToList() ?? [];
|
||||
}
|
||||
|
||||
public string Sanitize(string? input) => ApplyRules(input, _rules);
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
{
|
||||
var rx = new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
_rules.Add(new SanitizationRule(rx, replacement, description));
|
||||
}
|
||||
|
||||
public void RemoveRule(string description)
|
||||
{
|
||||
_rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _rules.AsReadOnly();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
{
|
||||
var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase));
|
||||
if (rule.Regex is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (rule.Evaluator is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Evaluator);
|
||||
}
|
||||
|
||||
if (rule.Replacement is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Replacement);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore exceptions for test determinism
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,25 @@ public sealed partial class AppListItem : ListItem
|
||||
{
|
||||
private readonly AppCommand _appCommand;
|
||||
private readonly AppItem _app;
|
||||
private readonly Lazy<Details> _details;
|
||||
private readonly Lazy<Task<IconInfo?>> _iconLoadTask;
|
||||
private readonly Lazy<Task<Details>> _detailsLoadTask;
|
||||
|
||||
private InterlockedBoolean _isLoadingIcon;
|
||||
private InterlockedBoolean _isLoadingDetails;
|
||||
|
||||
public override IDetails? Details { get => _details.Value; set => base.Details = value; }
|
||||
public override IDetails? Details
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_isLoadingDetails.Set())
|
||||
{
|
||||
_ = LoadDetailsAsync();
|
||||
}
|
||||
|
||||
return base.Details;
|
||||
}
|
||||
set => base.Details = value;
|
||||
}
|
||||
|
||||
public override IIconInfo? Icon
|
||||
{
|
||||
@@ -52,16 +65,22 @@ public sealed partial class AppListItem : ListItem
|
||||
|
||||
MoreCommands = AddPinCommands(_app.Commands!, isPinned);
|
||||
|
||||
_details = new Lazy<Details>(() =>
|
||||
{
|
||||
var t = BuildDetails();
|
||||
t.Wait();
|
||||
return t.Result;
|
||||
});
|
||||
|
||||
_detailsLoadTask = new Lazy<Task<Details>>(BuildDetails);
|
||||
_iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails));
|
||||
}
|
||||
|
||||
private async Task LoadDetailsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Details = await _detailsLoadTask.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning($"Failed to load details for {AppIdentifier}\n{ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadIconAsync()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -42,13 +42,13 @@ internal static class Commands
|
||||
var results = new List<IListItem>();
|
||||
results.AddRange(new[]
|
||||
{
|
||||
new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_shutdown_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/s /hybrid /t 0")))
|
||||
new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_shutdown_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/s /hybrid /t 0", runWithHiddenWindow: true)))
|
||||
{
|
||||
Title = Resources.Microsoft_plugin_sys_shutdown_computer,
|
||||
Subtitle = Resources.Microsoft_plugin_sys_shutdown_computer_description,
|
||||
Icon = Icons.ShutdownIcon,
|
||||
},
|
||||
new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_restart_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/g /t 0")))
|
||||
new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_restart_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/g /t 0", runWithHiddenWindow: true)))
|
||||
{
|
||||
Title = Resources.Microsoft_plugin_sys_restart_computer,
|
||||
Subtitle = Resources.Microsoft_plugin_sys_restart_computer_description,
|
||||
|
||||
@@ -197,16 +197,10 @@ namespace Peek.UI
|
||||
|
||||
ViewModel.Initialize(selectedItem);
|
||||
|
||||
// If no files were found (e.g., in virtual folders like Home/Recent), show an error
|
||||
// If no files were found (e.g., user is typing in rename/search box, or in virtual folders),
|
||||
// don't show anything - just return silently to avoid stealing focus
|
||||
if (ViewModel.CurrentItem == null)
|
||||
{
|
||||
Logger.LogInfo("Peek: No files found to preview, showing error.");
|
||||
var errorMessage = ResourceLoaderInstance.ResourceLoader.GetString("NoFilesSelected");
|
||||
ViewModel.ShowError(errorMessage);
|
||||
|
||||
// Still show the window so user can see the warning
|
||||
this.Show();
|
||||
WindowHelpers.BringToForeground(this.GetWindowHandle());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,21 +34,21 @@ public sealed class AdvancedPastePasteAsFileAction : Observable, IAdvancedPasteA
|
||||
public AdvancedPasteAdditionalAction PasteAsTxtFile
|
||||
{
|
||||
get => _pasteAsTxtFile;
|
||||
init => Set(ref _pasteAsTxtFile, value);
|
||||
init => Set(ref _pasteAsTxtFile, value ?? new());
|
||||
}
|
||||
|
||||
[JsonPropertyName(PropertyNames.PasteAsPngFile)]
|
||||
public AdvancedPasteAdditionalAction PasteAsPngFile
|
||||
{
|
||||
get => _pasteAsPngFile;
|
||||
init => Set(ref _pasteAsPngFile, value);
|
||||
init => Set(ref _pasteAsPngFile, value ?? new());
|
||||
}
|
||||
|
||||
[JsonPropertyName(PropertyNames.PasteAsHtmlFile)]
|
||||
public AdvancedPasteAdditionalAction PasteAsHtmlFile
|
||||
{
|
||||
get => _pasteAsHtmlFile;
|
||||
init => Set(ref _pasteAsHtmlFile, value);
|
||||
init => Set(ref _pasteAsHtmlFile, value ?? new());
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
|
||||
@@ -93,11 +93,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
|
||||
[JsonPropertyName("custom-actions")]
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
public AdvancedPasteCustomActions CustomActions { get; init; }
|
||||
public AdvancedPasteCustomActions CustomActions { get; set; }
|
||||
|
||||
[JsonPropertyName("additional-actions")]
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
public AdvancedPasteAdditionalActions AdditionalActions { get; init; }
|
||||
public AdvancedPasteAdditionalActions AdditionalActions { get; set; }
|
||||
|
||||
[JsonPropertyName("paste-ai-configuration")]
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
|
||||
@@ -32,14 +32,14 @@ public sealed class AdvancedPasteTranscodeAction : Observable, IAdvancedPasteAct
|
||||
public AdvancedPasteAdditionalAction TranscodeToMp3
|
||||
{
|
||||
get => _transcodeToMp3;
|
||||
init => Set(ref _transcodeToMp3, value);
|
||||
init => Set(ref _transcodeToMp3, value ?? new());
|
||||
}
|
||||
|
||||
[JsonPropertyName(PropertyNames.TranscodeToMp4)]
|
||||
public AdvancedPasteAdditionalAction TranscodeToMp4
|
||||
{
|
||||
get => _transcodeToMp4;
|
||||
init => Set(ref _transcodeToMp4, value);
|
||||
init => Set(ref _transcodeToMp4, value ?? new());
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
|
||||
@@ -22,11 +22,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonPropertyName("disable_wrap_during_drag")]
|
||||
public BoolProperty DisableWrapDuringDrag { get; set; }
|
||||
|
||||
[JsonPropertyName("wrap_mode")]
|
||||
public IntProperty WrapMode { get; set; }
|
||||
|
||||
public CursorWrapProperties()
|
||||
{
|
||||
ActivationShortcut = DefaultActivationShortcut;
|
||||
AutoActivate = new BoolProperty(false);
|
||||
DisableWrapDuringDrag = new BoolProperty(true);
|
||||
WrapMode = new IntProperty(0); // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,16 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
// This can be utilized in the future if the settings.json file is to be modified/deleted.
|
||||
public bool UpgradeSettingsConfiguration()
|
||||
{
|
||||
return false;
|
||||
bool settingsUpgraded = false;
|
||||
|
||||
// Add WrapMode property if it doesn't exist (for users upgrading from older versions)
|
||||
if (Properties.WrapMode == null)
|
||||
{
|
||||
Properties.WrapMode = new IntProperty(0); // Default to Both
|
||||
settingsUpgraded = true;
|
||||
}
|
||||
|
||||
return settingsUpgraded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Windows;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.Windows.ApplicationModel.Resources;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Converters
|
||||
{
|
||||
public partial class HotkeySettingsToLocalizedStringConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
if (value is HotkeySettings keySettings && parameter is string resourceKey)
|
||||
{
|
||||
return string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString(resourceKey), keySettings.ToString());
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Converters
|
||||
{
|
||||
public sealed partial class ZoomItOpacitySliderConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
// Slider value is 1-100, display as percentage
|
||||
int percentage = System.Convert.ToInt32((double)value);
|
||||
return $"{percentage}%";
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,9 @@
|
||||
<None Update="Assets\Settings\Scripts\DisableModule.ps1">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<Page Update="SettingsXAML\Controls\ShortcutControl\ShortcutWithTextLabelControl.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Update="SettingsXAML\Controls\TitleBar\TitleBar.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
|
||||
xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters"
|
||||
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
@@ -18,7 +19,7 @@
|
||||
<ResourceDictionary Source="/SettingsXAML/Themes/Colors.xaml" />
|
||||
<ResourceDictionary Source="/SettingsXAML/Themes/Generic.xaml" />
|
||||
<ResourceDictionary Source="/SettingsXAML/Controls/Timeline/TimelineStyles.xaml" />
|
||||
|
||||
<ResourceDictionary Source="/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml" />
|
||||
<!-- Other merged dictionaries here -->
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
@@ -81,9 +82,6 @@
|
||||
<RepositionThemeTransition IsStaggeringEnabled="False" />
|
||||
<!-- Smoothly animates individual cards upon whenever Expanders are expanded/collapsed -->
|
||||
</TransitionCollection>
|
||||
|
||||
<!-- Additional resources or settings can be added here -->
|
||||
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
||||
@@ -1,58 +1,67 @@
|
||||
<UserControl
|
||||
x:Class="Microsoft.PowerToys.Settings.UI.Controls.ShortcutWithTextLabelControl"
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
|
||||
d:DesignHeight="300"
|
||||
d:DesignWidth="400"
|
||||
mc:Ignorable="d">
|
||||
xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls"
|
||||
xmlns:tk="using:CommunityToolkit.WinUI"
|
||||
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls">
|
||||
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ItemsControl
|
||||
x:Name="ShortcutsControl"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
IsTabStop="False"
|
||||
ItemsSource="{x:Bind Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<controls:KeyVisual
|
||||
Padding="12,8,12,8"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Content="{Binding}"
|
||||
FontSize="12"
|
||||
IsTabStop="False"
|
||||
Style="{StaticResource DefaultKeyVisualStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<tkcontrols:MarkdownTextBlock
|
||||
x:Name="LabelControl"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind Text}" />
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="LabelPlacementStates">
|
||||
<VisualState x:Name="LabelAfter" />
|
||||
<VisualState x:Name="LabelBefore">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="LabelControl.(Grid.Column)" Value="0" />
|
||||
<Setter Target="ShortcutsControl.(Grid.Column)" Value="1" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
<Style BasedOn="{StaticResource DefaultShortcutWithTextLabelControlStyle}" TargetType="local:ShortcutWithTextLabelControl" />
|
||||
|
||||
<Style x:Key="DefaultShortcutWithTextLabelControlStyle" TargetType="local:ShortcutWithTextLabelControl">
|
||||
<Setter Property="KeyVisualStyle" Value="{StaticResource DefaultKeyVisualStyle}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:ShortcutWithTextLabelControl">
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ItemsControl
|
||||
x:Name="ShortcutsControl"
|
||||
VerticalAlignment="Bottom"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
IsTabStop="False"
|
||||
ItemsSource="{TemplateBinding Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Vertical">
|
||||
<local:KeyVisual
|
||||
tk:FrameworkElementExtensions.AncestorType="local:ShortcutWithTextLabelControl"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Content="{Binding}"
|
||||
IsTabStop="False"
|
||||
Style="{Binding (tk:FrameworkElementExtensions.Ancestor).KeyVisualStyle, RelativeSource={RelativeSource Self}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<tkcontrols:MarkdownTextBlock
|
||||
x:Name="LabelControl"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Config="{TemplateBinding MarkdownConfig}"
|
||||
Text="{TemplateBinding Text}" />
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="LabelPlacementStates">
|
||||
<VisualState x:Name="LabelAfter" />
|
||||
<VisualState x:Name="LabelBefore">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="LabelControl.(Grid.Column)" Value="0" />
|
||||
<Setter Target="ShortcutsControl.(Grid.Column)" Value="1" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
using CommunityToolkit.WinUI.Controls;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
{
|
||||
public sealed partial class ShortcutWithTextLabelControl : UserControl
|
||||
public sealed partial class ShortcutWithTextLabelControl : Control
|
||||
{
|
||||
public string Text
|
||||
{
|
||||
@@ -27,26 +27,47 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
|
||||
public static readonly DependencyProperty KeysProperty = DependencyProperty.Register(nameof(Keys), typeof(List<object>), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string)));
|
||||
|
||||
public LabelPlacement LabelPlacement
|
||||
public Placement LabelPlacement
|
||||
{
|
||||
get { return (LabelPlacement)GetValue(LabelPlacementProperty); }
|
||||
get { return (Placement)GetValue(LabelPlacementProperty); }
|
||||
set { SetValue(LabelPlacementProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty LabelPlacementProperty = DependencyProperty.Register(nameof(LabelPlacement), typeof(LabelPlacement), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(defaultValue: LabelPlacement.After, OnIsLabelPlacementChanged));
|
||||
public static readonly DependencyProperty LabelPlacementProperty = DependencyProperty.Register(nameof(LabelPlacement), typeof(Placement), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(defaultValue: Placement.After, OnIsLabelPlacementChanged));
|
||||
|
||||
public MarkdownConfig MarkdownConfig
|
||||
{
|
||||
get { return (MarkdownConfig)GetValue(MarkdownConfigProperty); }
|
||||
set { SetValue(MarkdownConfigProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty MarkdownConfigProperty = DependencyProperty.Register(nameof(MarkdownConfig), typeof(MarkdownConfig), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(new MarkdownConfig()));
|
||||
|
||||
public Style KeyVisualStyle
|
||||
{
|
||||
get { return (Style)GetValue(KeyVisualStyleProperty); }
|
||||
set { SetValue(KeyVisualStyleProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty KeyVisualStyleProperty = DependencyProperty.Register(nameof(KeyVisualStyle), typeof(Style), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(Style)));
|
||||
|
||||
public ShortcutWithTextLabelControl()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
DefaultStyleKey = typeof(ShortcutWithTextLabelControl);
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate()
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
}
|
||||
|
||||
private static void OnIsLabelPlacementChanged(DependencyObject d, DependencyPropertyChangedEventArgs newValue)
|
||||
{
|
||||
if (d is ShortcutWithTextLabelControl labelControl)
|
||||
{
|
||||
if (labelControl.LabelPlacement == LabelPlacement.Before)
|
||||
if (labelControl.LabelPlacement == Placement.Before)
|
||||
{
|
||||
VisualStateManager.GoToState(labelControl, "LabelBefore", true);
|
||||
VisualStateManager.GoToState(labelControl, "LabelBefore", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -54,11 +75,11 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum LabelPlacement
|
||||
{
|
||||
Before,
|
||||
After,
|
||||
public enum Placement
|
||||
{
|
||||
Before,
|
||||
After,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@
|
||||
<tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind ViewModel.IsCursorWrapEnabled, Mode=OneWay}">
|
||||
<CheckBox x:Uid="MouseUtils_CursorWrap_DisableWrapDuringDrag" IsChecked="{x:Bind ViewModel.CursorWrapDisableWrapDuringDrag, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="MouseUtilsCursorWrapWrapMode" x:Uid="MouseUtils_CursorWrap_WrapMode">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.CursorWrapWrapMode, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="MouseUtils_CursorWrap_WrapMode_Both" />
|
||||
<ComboBoxItem x:Uid="MouseUtils_CursorWrap_WrapMode_VerticalOnly" />
|
||||
<ComboBoxItem x:Uid="MouseUtils_CursorWrap_WrapMode_HorizontalOnly" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<local:NavigablePage
|
||||
<local:NavigablePage
|
||||
x:Class="Microsoft.PowerToys.Settings.UI.Views.ZoomItPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
@@ -11,12 +11,21 @@
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
AutomationProperties.LandmarkType="Main"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<local:NavigablePage.Resources>
|
||||
<converters:ZoomItInitialZoomConverter x:Key="ZoomItInitialZoomConverter" />
|
||||
<converters:ZoomItTypeSpeedSliderConverter x:Key="ZoomItTypeSpeedSliderConverter" />
|
||||
<converters:ZoomItOpacitySliderConverter x:Key="ZoomItOpacitySliderConverter" />
|
||||
<converters:HotkeySettingsToLocalizedStringConverter x:Key="HotkeySettingsToLocalizedStringConverter" />
|
||||
<tkcontrols:MarkdownThemes
|
||||
x:Key="ZoomItMarkdownThemeConfig"
|
||||
InlineCodeBackground="{StaticResource ControlFillColorDefaultBrush}"
|
||||
InlineCodeBorderBrush="{StaticResource ControlElevationBorderBrush}"
|
||||
InlineCodeCornerRadius="2"
|
||||
InlineCodeFontSize="12"
|
||||
InlineCodeForeground="{StaticResource TextFillColorSecondaryBrush}"
|
||||
InlineCodePadding="2,0,2,1" />
|
||||
<tkcontrols:MarkdownConfig x:Key="ZoomItMarkdownConfig" Themes="{StaticResource ZoomItMarkdownThemeConfig}" />
|
||||
</local:NavigablePage.Resources>
|
||||
|
||||
<controls:SettingsPageControl
|
||||
x:Uid="ZoomIt"
|
||||
IsTabStop="False"
|
||||
@@ -38,55 +47,82 @@
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</controls:GPOInfoControl>
|
||||
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_BehaviorGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard Name="ZoomItToggleShowTrayIcon" x:Uid="ZoomIt_Toggle_ShowTrayIcon">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.ShowTrayIcon, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_ZoomGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItZoomShortcut"
|
||||
x:Uid="ZoomIt_Zoom_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.ZoomToggleKey, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItToggleAnimateZoom" x:Uid="ZoomIt_Toggle_AnimateZoom">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.AnimateZoom, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItSmoothZoomedImage" x:Uid="ZoomIt_Toggle_SmoothZoomedImage">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.SmoothImage, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItSliderInitialMagnification" x:Uid="ZoomIt_Slider_InitialMagnification">
|
||||
<Slider
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
Maximum="5"
|
||||
Minimum="0"
|
||||
ThumbToolTipValueConverter="{StaticResource ZoomItInitialZoomConverter}"
|
||||
TickFrequency="1"
|
||||
TickPlacement="Outside"
|
||||
Value="{x:Bind ViewModel.ZoominSliderLevel, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.ZoomToggleKey, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard Name="ZoomItToggleAnimateZoom" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="ZoomIt_Toggle_AnimateZoom" IsChecked="{x:Bind ViewModel.AnimateZoom, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItSmoothZoomedImage" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="ZoomIt_Toggle_SmoothZoomedImage" IsChecked="{x:Bind ViewModel.SmoothImage, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItSliderInitialMagnification" x:Uid="ZoomIt_Slider_InitialMagnification">
|
||||
<Slider
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
Maximum="5"
|
||||
Minimum="0"
|
||||
ThumbToolTipValueConverter="{StaticResource ZoomItInitialZoomConverter}"
|
||||
TickFrequency="1"
|
||||
TickPlacement="Outside"
|
||||
Value="{x:Bind ViewModel.ZoominSliderLevel, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<tkcontrols:MarkdownTextBlock x:Uid="ZoomIt_ZoomFAQ" Config="{StaticResource ZoomItMarkdownConfig}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_LiveZoomGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItLiveZoomShortcut"
|
||||
x:Uid="ZoomIt_LiveZoom_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.LiveZoomToggleKey, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.LiveZoomToggleKey, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<tkcontrols:MarkdownTextBlock Config="{StaticResource ZoomItMarkdownConfig}" Text="{x:Bind ViewModel.LiveZoomToggleKeyDraw, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_LiveZoom_Shortcut_Draw}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_DrawGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItDrawShortcut"
|
||||
x:Uid="ZoomIt_Draw_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.DrawToggleKey, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.DrawToggleKey, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<tkcontrols:MarkdownTextBlock x:Uid="ZoomIt_DrawFAQ" Config="{StaticResource ZoomItMarkdownConfig}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_TypeGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard Name="ZoomItTypeTextFont" x:Uid="ZoomIt_Type_TextFont">
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItTypeTextFont"
|
||||
x:Uid="ZoomIt_Type_TextFont"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<tkcontrols:SettingsExpander.Description>
|
||||
<TextBlock
|
||||
FontFamily="{x:Bind ViewModel.DemoSampleFontFamily, Mode=OneWay}"
|
||||
FontSize="{x:Bind ViewModel.DemoSampleFontSize, Mode=OneWay}"
|
||||
@@ -94,178 +130,202 @@
|
||||
FontWeight="{x:Bind ViewModel.DemoSampleFontWeight, Mode=OneWay}"
|
||||
Text="Sample"
|
||||
TextDecorations="{x:Bind ViewModel.DemoSampleTextDecoration, Mode=OneWay}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsExpander.Description>
|
||||
<Button x:Uid="ZoomIt_Type_Font_Button" Command="{x:Bind ViewModel.SelectTypeFontCommand, Mode=OneWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<tkcontrols:MarkdownTextBlock x:Uid="ZoomIt_TypeFAQ" Config="{StaticResource ZoomItMarkdownConfig}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_DemoTypeGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItDemoTypeFile"
|
||||
x:Uid="ZoomIt_DemoType_File"
|
||||
Description="{x:Bind ViewModel.DemoTypeFile, Mode=OneWay}">
|
||||
<Button x:Uid="ZoomIt_DemoType_File_BrowseButton" Command="{x:Bind ViewModel.SelectDemoTypeFileCommand, Mode=OneWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItDemoTypeShortcut"
|
||||
x:Uid="ZoomIt_DemoType_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.DemoTypeToggleKey, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItDemoTypeToggleUserDrivenMode" x:Uid="ZoomIt_DemoType_Toggle_UserDrivenMode">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.DemoTypeUserDrivenMode, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItDemoTypeSpeedSlider"
|
||||
x:Uid="ZoomIt_DemoType_SpeedSlider"
|
||||
Description="{x:Bind ViewModel.DemoTypeSpeedSlider, Mode=OneWay}">
|
||||
<Slider
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
Maximum="{x:Bind ViewModel.DemoTypeMinTypingSpeed, Mode=OneWay}"
|
||||
Minimum="{x:Bind ViewModel.DemoTypeMaxTypingSpeed, Mode=OneWay}"
|
||||
ThumbToolTipValueConverter="{StaticResource ZoomItTypeSpeedSliderConverter}"
|
||||
Value="{x:Bind ViewModel.DemoTypeSpeedSlider, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_BreakGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItBreakShortcut"
|
||||
x:Uid="ZoomIt_Break_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.BreakTimerKey, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakTimeout" x:Uid="ZoomIt_Break_Timeout">
|
||||
<NumberBox
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
LargeChange="10"
|
||||
Maximum="99"
|
||||
Minimum="1"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.BreakTimeout, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakShowExpiredTime" x:Uid="ZoomIt_Break_ShowExpiredTime">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.BreakShowExpiredTime, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItBreakPlaySoundsFile"
|
||||
x:Uid="ZoomIt_Break_PlaySoundsFile"
|
||||
Name="ZoomItDemoTypeShortcut"
|
||||
x:Uid="ZoomIt_DemoType_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.BreakPlaySoundFile, Mode=TwoWay}" />
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.DemoTypeToggleKey, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItDemoTypeFile"
|
||||
x:Uid="ZoomIt_DemoType_File"
|
||||
Description="{x:Bind ViewModel.DemoTypeFile, Mode=OneWay}">
|
||||
<Button x:Uid="ZoomIt_DemoType_File_BrowseButton" Command="{x:Bind ViewModel.SelectDemoTypeFileCommand, Mode=OneWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItDemoTypeToggleUserDrivenMode" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="ZoomIt_DemoType_Toggle_UserDrivenMode" IsChecked="{x:Bind ViewModel.DemoTypeUserDrivenMode, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItDemoTypeSpeedSlider" x:Uid="ZoomIt_DemoType_SpeedSlider">
|
||||
<Slider
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
Maximum="{x:Bind ViewModel.DemoTypeMinTypingSpeed, Mode=OneWay}"
|
||||
Minimum="{x:Bind ViewModel.DemoTypeMaxTypingSpeed, Mode=OneWay}"
|
||||
ThumbToolTipValueConverter="{StaticResource ZoomItTypeSpeedSliderConverter}"
|
||||
Value="{x:Bind ViewModel.DemoTypeSpeedSlider, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItDemoTypeShortcutReset">
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<tkcontrols:MarkdownTextBlock Config="{StaticResource ZoomItMarkdownConfig}" Text="{x:Bind ViewModel.DemoTypeToggleKeyReset, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_DemoTypeFAQ}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_BreakGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItBreakShortcut"
|
||||
x:Uid="ZoomIt_Break_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.BreakTimerKey, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakTimeout" x:Uid="ZoomIt_Break_Timeout">
|
||||
<NumberBox
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
LargeChange="10"
|
||||
Maximum="99"
|
||||
Minimum="1"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.BreakTimeout, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakShowExpiredTime" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="ZoomIt_Break_ShowExpiredTime" IsChecked="{x:Bind ViewModel.BreakShowExpiredTime, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakPlaySoundsFile" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="ZoomIt_Break_PlaySoundsFile" IsChecked="{x:Bind ViewModel.BreakPlaySoundFile, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItBreakSoundFile"
|
||||
x:Uid="ZoomIt_Break_SoundFile"
|
||||
Description="{x:Bind ViewModel.BreakSoundFile, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind ViewModel.BreakPlaySoundFile, Mode=OneWay}">
|
||||
Visibility="{x:Bind ViewModel.BreakPlaySoundFile, Mode=OneWay}">
|
||||
<Button x:Uid="ZoomIt_Break_SoundFile_BrowseButton" Command="{x:Bind ViewModel.SelectBreakSoundFileCommand, Mode=OneWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakTimerOpacity" x:Uid="ZoomIt_Break_TimerOpacity">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.BreakTimerOpacityIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_10Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_20Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_30Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_40Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_50Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_60Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_70Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_80Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_90Percent" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_100Percent" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakTimerPosition" x:Uid="ZoomIt_Break_TimerPosition">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.BreakTimerPosition, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopLeftCorner" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopCenter" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopRightCorner" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Left" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Center" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Right" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomLeftCorner" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomCenter" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomRightCorner" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItBreakShowBackgroundBitmap"
|
||||
x:Uid="ZoomIt_Break_ShowBackgroundBitmap"
|
||||
IsExpanded="True">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItBreakShowDesktopOrImageFile"
|
||||
x:Uid="ZoomIt_Break_ShowDesktopOrImageFile"
|
||||
IsEnabled="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}">
|
||||
<RadioButtons SelectedIndex="{x:Bind ViewModel.BreakShowDesktopOrImageFileIndex, Mode=TwoWay}">
|
||||
<RadioButton x:Uid="ZoomIt_Break_ShowFadedDesktop" />
|
||||
<RadioButton x:Uid="ZoomIt_Break_ShowImageFile" />
|
||||
</RadioButtons>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakTimerOpacity" x:Uid="ZoomIt_Break_TimerOpacity">
|
||||
<Slider
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
Maximum="100"
|
||||
Minimum="1"
|
||||
ThumbToolTipValueConverter="{StaticResource ZoomItOpacitySliderConverter}"
|
||||
Value="{x:Bind ViewModel.BreakTimerOpacity, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakTimerPosition" x:Uid="ZoomIt_Break_TimerPosition">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.BreakTimerPosition, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopLeftCorner" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopCenter" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopRightCorner" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Left" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Center" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Right" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomLeftCorner" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomCenter" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomRightCorner" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
|
||||
<tkcontrols:SettingsCard Name="ZoomItBreakShowBackgroundBitmap" x:Uid="ZoomIt_Break_ShowBackgroundBitmap">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.BreakBackgroundSelectionIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_BackgroundImage_None" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_ShowFadedDesktop" />
|
||||
<ComboBoxItem x:Uid="ZoomIt_Break_ShowImageFile" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItBreakBackgroundFile"
|
||||
x:Uid="ZoomIt_Break_BackgroundFile"
|
||||
Description="{x:Bind ViewModel.BreakBackgroundFile, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}">
|
||||
Visibility="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}">
|
||||
<Button x:Uid="ZoomIt_Break_BackgroundFile_BrowseButton" Command="{x:Bind ViewModel.SelectBreakBackgroundFileCommand, Mode=OneWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItBreakBackgroundStretch"
|
||||
x:Uid="ZoomIt_Break_BackgroundStretch"
|
||||
IsEnabled="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.BreakBackgroundStretch, Mode=TwoWay}" />
|
||||
ContentAlignment="Left"
|
||||
Visibility="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}">
|
||||
<CheckBox x:Uid="ZoomIt_Break_BackgroundStretch" IsChecked="{x:Bind ViewModel.BreakBackgroundStretch, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<tkcontrols:MarkdownTextBlock x:Uid="ZoomIt_BreakFAQ" Config="{StaticResource ZoomItMarkdownConfig}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
|
||||
</controls:SettingsGroup>
|
||||
|
||||
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_RecordGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItRecordShortcut"
|
||||
x:Uid="ZoomIt_Record_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.RecordToggleKey, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard Name="ZoomItRecordScaling" x:Uid="ZoomIt_Record_Scaling">
|
||||
<Slider
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
Maximum="1"
|
||||
Minimum="0.1"
|
||||
StepFrequency="0.1"
|
||||
TickFrequency="0.1"
|
||||
TickPlacement="Outside"
|
||||
Value="{x:Bind ViewModel.RecordScaling, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItRecordFormat" x:Uid="ZoomIt_Record_Format">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.RecordFormatIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem>GIF</ComboBoxItem>
|
||||
<ComboBoxItem>MP4</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItRecordCaptureAudio" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="ZoomIt_Record_CaptureAudio" IsChecked="{x:Bind ViewModel.RecordCaptureAudio, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItRecordMicrophone"
|
||||
x:Uid="ZoomIt_Record_Microphone"
|
||||
Visibility="{x:Bind ViewModel.RecordCaptureAudio, Mode=OneWay}">
|
||||
<ComboBox
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
DisplayMemberPath="Item2"
|
||||
ItemsSource="{x:Bind ViewModel.MicrophoneList}"
|
||||
SelectedValue="{x:Bind ViewModel.RecordMicrophoneDeviceId, Mode=TwoWay}"
|
||||
SelectedValuePath="Item1" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<StackPanel Orientation="Vertical" Spacing="4">
|
||||
<tkcontrols:MarkdownTextBlock Config="{StaticResource ZoomItMarkdownConfig}" Text="{x:Bind ViewModel.RecordToggleKey, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Record_Shortcut_FullScreen}" />
|
||||
<tkcontrols:MarkdownTextBlock Config="{StaticResource ZoomItMarkdownConfig}" Text="{x:Bind ViewModel.RecordToggleKeyCrop, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Record_Shortcut_Crop}" />
|
||||
<tkcontrols:MarkdownTextBlock Config="{StaticResource ZoomItMarkdownConfig}" Text="{x:Bind ViewModel.RecordToggleKeyWindow, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Record_Shortcut_Window}" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_RecordGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard
|
||||
Name="ZoomItRecordShortcut"
|
||||
x:Uid="ZoomIt_Record_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.RecordToggleKey, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItRecordScaling" x:Uid="ZoomIt_Record_Scaling">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.RecordScalingIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem>0.1</ComboBoxItem>
|
||||
<ComboBoxItem>0.2</ComboBoxItem>
|
||||
<ComboBoxItem>0.3</ComboBoxItem>
|
||||
<ComboBoxItem>0.4</ComboBoxItem>
|
||||
<ComboBoxItem>0.5</ComboBoxItem>
|
||||
<ComboBoxItem>0.6</ComboBoxItem>
|
||||
<ComboBoxItem>0.7</ComboBoxItem>
|
||||
<ComboBoxItem>0.8</ComboBoxItem>
|
||||
<ComboBoxItem>0.9</ComboBoxItem>
|
||||
<ComboBoxItem>1.0</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItRecordFormat" x:Uid="ZoomIt_Record_Format">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.RecordFormatIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem>GIF</ComboBoxItem>
|
||||
<ComboBoxItem>MP4</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItRecordCaptureAudio" x:Uid="ZoomIt_Record_CaptureAudio">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.RecordCaptureAudio, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="ZoomItRecordMicrophone" x:Uid="ZoomIt_Record_Microphone">
|
||||
<ComboBox
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
DisplayMemberPath="Item2"
|
||||
ItemsSource="{x:Bind ViewModel.MicrophoneList}"
|
||||
SelectedValue="{x:Bind Path=ViewModel.RecordMicrophoneDeviceId, Mode=TwoWay}"
|
||||
SelectedValuePath="Item1" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</controls:SettingsGroup>
|
||||
<controls:SettingsGroup x:Uid="ZoomIt_SnipGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard
|
||||
<tkcontrols:SettingsExpander
|
||||
Name="ZoomItSnipShortcut"
|
||||
x:Uid="ZoomIt_Snip_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.SnipToggleKey, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.SnipToggleKey, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard Name="ZoomItSnipShortcutSave">
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<tkcontrols:MarkdownTextBlock Config="{StaticResource ZoomItMarkdownConfig}" Text="{x:Bind ViewModel.SnipToggleKeySave, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Snip_Shortcut_Save}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
</StackPanel>
|
||||
</controls:SettingsPageControl.ModuleContent>
|
||||
@@ -273,7 +333,7 @@
|
||||
<controls:PageLink x:Uid="LearnMore_ZoomIt" Link="https://aka.ms/PowerToysOverview_ZoomIt" />
|
||||
</controls:SettingsPageControl.PrimaryLinks>
|
||||
<controls:SettingsPageControl.SecondaryLinks>
|
||||
<controls:PageLink Link="https://learn.microsoft.com/en-us/sysinternals/downloads/zoomit" Text="Sysinternals Zoomit by Mark Russinovich, Alex Mihaiuc, John Stephens" />
|
||||
<controls:PageLink Link="https://learn.microsoft.com/sysinternals/downloads/zoomit" Text="Sysinternals ZoomIt by Mark Russinovich, Alex Mihaiuc, John Stephens" />
|
||||
</controls:SettingsPageControl.SecondaryLinks>
|
||||
</controls:SettingsPageControl>
|
||||
</local:NavigablePage>
|
||||
|
||||
@@ -2728,6 +2728,18 @@ From there, simply click on one of the supported files in the File Explorer and
|
||||
<data name="MouseUtils_CursorWrap_AutoActivate.Content" xml:space="preserve">
|
||||
<value>Automatically activate on utility startup</value>
|
||||
</data>
|
||||
<data name="MouseUtils_CursorWrap_WrapMode.Header" xml:space="preserve">
|
||||
<value>Wrap mode</value>
|
||||
</data>
|
||||
<data name="MouseUtils_CursorWrap_WrapMode_VerticalOnly.Content" xml:space="preserve">
|
||||
<value>Vertical only</value>
|
||||
</data>
|
||||
<data name="MouseUtils_CursorWrap_WrapMode_HorizontalOnly.Content" xml:space="preserve">
|
||||
<value>Horizontal only</value>
|
||||
</data>
|
||||
<data name="MouseUtils_CursorWrap_WrapMode_Both.Content" xml:space="preserve">
|
||||
<value>Vertical and horizontal</value>
|
||||
</data>
|
||||
<data name="Oobe_MouseUtils_MousePointerCrosshairs.Text" xml:space="preserve">
|
||||
<value>Mouse Pointer Crosshairs</value>
|
||||
<comment>Mouse as in the hardware peripheral.</comment>
|
||||
@@ -4803,52 +4815,58 @@ Activate by holding the key for the character you want to add an accent to, then
|
||||
<value>Zoom</value>
|
||||
</data>
|
||||
<data name="ZoomIt_ZoomGroup.Description" xml:space="preserve">
|
||||
<value>After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.
|
||||
|
||||
Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.</value>
|
||||
<value>Zoom in or out to enlarge content and make details clearer.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_ZoomFAQ.Text" xml:space="preserve">
|
||||
<value>Press **the mouse wheel** or **the Up / Down arrow keys** to zoom in or out.
|
||||
Press **Esc** or **the right mouse button** to exit zoom mode.
|
||||
Press **Ctrl + C** to capture the zoomed view, or **Ctrl + S** to save it.
|
||||
Press **Ctrl + Shift** to crop before copying or saving.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Zoom_Shortcut.Header" xml:space="preserve">
|
||||
<value>Zoom hotkey</value>
|
||||
<value>Zoom activation</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Toggle_AnimateZoom.Header" xml:space="preserve">
|
||||
<value>Animate zoom in and zoom out</value>
|
||||
<data name="ZoomIt_Toggle_AnimateZoom.Content" xml:space="preserve">
|
||||
<value>Animate zoom in and out</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Toggle_SmoothZoomedImage.Header" xml:space="preserve">
|
||||
<value>Smooth zoomed image</value>
|
||||
<data name="ZoomIt_Toggle_SmoothZoomedImage.Content" xml:space="preserve">
|
||||
<value>Smooth the zoomed image</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Slider_InitialMagnification.Header" xml:space="preserve">
|
||||
<value>Specify the initial level of magnification when zooming in</value>
|
||||
<value>Initial zoom level</value>
|
||||
</data>
|
||||
<data name="ZoomIt_LiveZoomGroup.Header" xml:space="preserve">
|
||||
<value>Live Zoom</value>
|
||||
</data>
|
||||
<data name="ZoomIt_LiveZoomGroup.Description" xml:space="preserve">
|
||||
<value>LiveZoom mode supports window updates to show while zoomed.
|
||||
|
||||
Note that in LiveZoom you must use Ctrl+Up and Ctrl+Down to control the zoom level. To enter drawing mode, use the standard zoom-without-draw hotkey and then escape to go back to LiveZoom.
|
||||
|
||||
Use LiveDraw to draw and annotate the live desktop. To activate LiveDraw, enter the hotkey with the Shift key in the opposite mode. You can remove LiveDraw annotations by activating LiveDraw and enter the escape key.
|
||||
|
||||
To enter and exit LiveZoom, enter the hotkey specified below.</value>
|
||||
<value>Live Zoom keeps windows updating while zoomed.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_LiveZoom_Shortcut.Header" xml:space="preserve">
|
||||
<value>Live Zoom hotkey</value>
|
||||
<value>Live Zoom activation</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DrawGroup.Header" xml:space="preserve">
|
||||
<value>Draw</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DrawGroup.Description" xml:space="preserve">
|
||||
<value>Once zoomed, toggle drawing mode by pressing the left mouse button. Undo with Ctrl+Z and all drawing by pressing E. Center the cursor with the space bar. Exit drawing mode by pressing the right mouse button.
|
||||
<data name="ZoomIt_DrawFAQ.Text" xml:space="preserve">
|
||||
<value>Press **the left mouse button** to toggle drawing mode when zoomed in, and **the right mouse button** to exit.
|
||||
Press **Ctrl + Z** to undo, **E** to clear drawings, and **Space** to center the cursor.
|
||||
|
||||
Pen Control - Change the pen width by pressing left Ctrl and using the mouse wheel or the up and down arrow keys.
|
||||
**Pen control**
|
||||
Press **Ctrl + the mouse wheel** or **Ctrl + Up / Down** to adjust the pen width.
|
||||
|
||||
Colors - Change the pen color by pressing R (red), G (green), B (blue), O (orange), Y (yellow) or P (pink).
|
||||
**Colors**
|
||||
Press **R** (Red), **G** (Green), **B** (Blue), **O** (Orange), **Y** (Yellow), or **P** (Pink) to switch colors.
|
||||
|
||||
Highlight and Blur - Hold Shift while pressing a color key for a translucent highlighter color. Press X for blur or Shift+X for a stronger blur.
|
||||
**Highlight and blur**
|
||||
Press **Shift + a color key** for a translucent highlighter, **X** for blur, or **Shift + X** for a stronger blur.
|
||||
|
||||
Shapes - Draw a line by holding down the Shift key, a rectangle with the Ctrl key, an ellipse with the Tab key and an arrow with Shift+Ctrl.
|
||||
**Shapes**
|
||||
Press **Shift** for a line, **Ctrl** for a rectangle, **Tab** for an ellipse, or **Shift + Ctrl** for an arrow.
|
||||
|
||||
Screen - Clear the screen for a sketch pad by pressing W (white) or K (black). Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.</value>
|
||||
**Screen**
|
||||
Press **W** or **K** for a white or black sketch pad.
|
||||
Press **Ctrl + C** to copy or **Ctrl + S** to save, and **Ctrl + Shift** to crop.
|
||||
</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Draw_Shortcut.Header" xml:space="preserve">
|
||||
<value>Draw without zoom hotkey</value>
|
||||
@@ -4857,9 +4875,7 @@ Screen - Clear the screen for a sketch pad by pressing W (white) or K (black). C
|
||||
<value>Type</value>
|
||||
</data>
|
||||
<data name="ZoomIt_TypeGroup.Description" xml:space="preserve">
|
||||
<value>Once in drawing mode, type 't' to enter typing mode or shift+'t' to enter typing mode with right-aligned input. Exit typing mode by pressing escape or the left mouse button. Use the mouse wheel or up and down arrow keys to change the font size.
|
||||
|
||||
The text color is the current drawing color.</value>
|
||||
<value>Type text while drawing</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Type_TextFont.Header" xml:space="preserve">
|
||||
<value>Text font</value>
|
||||
@@ -4872,18 +4888,25 @@ The text color is the current drawing color.</value>
|
||||
<value>DemoType</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DemoTypeGroup.Description" xml:space="preserve">
|
||||
<value>Use DemoType to have ZoomIt type text specified in the input file when you enter the DemoType toggle. You can also pull input from the clipboard if it is prefixed with the [start] keyword.
|
||||
<value>Insert predefined text snippets with a shortcut using a text file.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DemoTypeFAQ" xml:space="preserve">
|
||||
<value>Text can be pulled from the clipboard when it starts with **[start]**.
|
||||
Use **[end]** to separate snippets, **[pause:n]** to insert pauses (in seconds), and **[paste]** / **[/paste]** to send clipboard text.
|
||||
Use **[enter]**, **[up]**, **[down]**, **[left]**, and **[right]** to issue keystrokes.
|
||||
|
||||
Separate snippets with the [end] keyword and insert pauses into the text output with the [pause:n] keyword where 'n' is seconds. Send text via the clipboard with [paste] and [/paste]. Send keystrokes with [enter], [up], [down], [left] and [right].
|
||||
ZoomIt can send text automatically or run in manual mode. Keyboard input is blocked while text is being sent.
|
||||
|
||||
You can have ZoomIt send text automatically, or select the option to drive input with typing. ZoomIt will block keyboard input while sending output.
|
||||
In manual mode, press **Space** to unblock keyboard input at the end of a snippet.
|
||||
In auto mode, control returns automatically after completion.
|
||||
|
||||
When driving input, hit the space bar to unblock keyboard input at the end of a snippet. In auto mode, control will be returned upon completion.
|
||||
At the end of the file, ZoomIt reloads the file and restarts from the beginning.
|
||||
Press the hotkey with **Shift** in the opposite mode to step back to the previous **[end]** marker.
|
||||
|
||||
When you reach the end of the file, ZoomIt will reload the file and start at the beginning. Enter the hotkey with the Shift key in the opposite mode to step back to the last [end].</value>
|
||||
Press **{0}** to reset DemoType and start from the beginning.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DemoType_Shortcut.Header" xml:space="preserve">
|
||||
<value>DemoType toggle hotkey</value>
|
||||
<value>DemoType activation</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DemoType_File.Header" xml:space="preserve">
|
||||
<value>Input file</value>
|
||||
@@ -4897,11 +4920,11 @@ When you reach the end of the file, ZoomIt will reload the file and start at the
|
||||
<data name="FilePicker_AllFilesFilter" xml:space="preserve">
|
||||
<value>All Files</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DemoType_Toggle_UserDrivenMode.Header" xml:space="preserve">
|
||||
<data name="ZoomIt_DemoType_Toggle_UserDrivenMode.Content" xml:space="preserve">
|
||||
<value>Drive input with typing</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DemoType_SpeedSlider.Header" xml:space="preserve">
|
||||
<value>DemoType typing speed</value>
|
||||
<value>Typing speed</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DemoType_SpeedSlider_Thumbnail_Explanation" xml:space="preserve">
|
||||
<value>bigger is faster</value>
|
||||
@@ -4910,20 +4933,27 @@ When you reach the end of the file, ZoomIt will reload the file and start at the
|
||||
<value>Break</value>
|
||||
</data>
|
||||
<data name="ZoomIt_BreakGroup.Description" xml:space="preserve">
|
||||
<value>Enter timer mode by using the ZoomIt tray icon's Break menu item. Increase and decrease time with the arrow keys. If you Alt-Tab away from the timer window, reactivate it by left-clicking on the ZoomIt tray icon. Exit timer mode with Escape.
|
||||
<value>Displays a countdown overlay for timed breaks or presentations.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_BreakFAQ.Text" xml:space="preserve">
|
||||
<value>Enter timer mode from the ZoomIt tray icon’s Break menu.
|
||||
Press **the arrow keys** to adjust the time. If the timer window loses focus through **Alt + Tab**, press **the left mouse button** on the ZoomIt tray icon to reactivate it.
|
||||
|
||||
Change the break timer color using the same keys that the drawing color. The break timer font is the same as text font.</value>
|
||||
Press **Esc** to exit timer mode.
|
||||
|
||||
Change the break timer color using the same keys as the drawing colors.
|
||||
The break timer font matches the text font.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_Shortcut.Header" xml:space="preserve">
|
||||
<value>Start break timer hotkey</value>
|
||||
<value>Break timer activation</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_Timeout.Header" xml:space="preserve">
|
||||
<value>Timer (minutes)</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_ShowExpiredTime.Header" xml:space="preserve">
|
||||
<data name="ZoomIt_Break_ShowExpiredTime.Content" xml:space="preserve">
|
||||
<value>Show time elapsed after expiration</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_PlaySoundsFile.Header" xml:space="preserve">
|
||||
<data name="ZoomIt_Break_PlaySoundsFile.Content" xml:space="preserve">
|
||||
<value>Play sound on expiration</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_SoundFile.Header" xml:space="preserve">
|
||||
@@ -5005,7 +5035,7 @@ Change the break timer color using the same keys that the drawing color. The bre
|
||||
<value>Show background bitmap</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_ShowFadedDesktop.Content" xml:space="preserve">
|
||||
<value>Use faded desktop as background</value>
|
||||
<value>Faded desktop</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_ShowImageFile.Content" xml:space="preserve">
|
||||
<value>Use image file as background</value>
|
||||
@@ -5020,26 +5050,31 @@ Change the break timer color using the same keys that the drawing color. The bre
|
||||
<value>Specify background file...</value>
|
||||
</data>
|
||||
<data name="FilePicker_ZoomIt_BitmapFilesFilter" xml:space="preserve">
|
||||
<value>Bitmap Files</value>
|
||||
<value>Bitmap files</value>
|
||||
</data>
|
||||
<data name="FilePicker_ZoomIt_AllPicturesFilter" xml:space="preserve">
|
||||
<value>All Picture Files</value>
|
||||
<value>All picture files</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_BackgroundStretch.Header" xml:space="preserve">
|
||||
<data name="ZoomIt_Break_BackgroundStretch.Content" xml:space="preserve">
|
||||
<value>Scale to screen</value>
|
||||
</data>
|
||||
<data name="ZoomIt_RecordGroup.Header" xml:space="preserve">
|
||||
<value>Record</value>
|
||||
</data>
|
||||
<data name="ZoomIt_RecordGroup.Description" xml:space="preserve">
|
||||
<value>Record video of the unzoomed live screen or a static zoomed session by entering the recording hotkey and finish the recording by entering it again.
|
||||
|
||||
To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode.
|
||||
|
||||
To record a specific window, enter the hotkey with the Alt key in the opposite mode.</value>
|
||||
<value>Record video of the screen.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Record_Shortcut.Header" xml:space="preserve">
|
||||
<value>Record hotkey</value>
|
||||
<value>Record activation</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Record_Shortcut_FullScreen" xml:space="preserve">
|
||||
<value>Press **{0}** to start or stop screen or zoom recording</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Record_Shortcut_Crop" xml:space="preserve">
|
||||
<value>Press **{0}** to record a portion of the screen</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Record_Shortcut_Window" xml:space="preserve">
|
||||
<value>Press **{0}** to record a specific window</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Record_Scaling.Header" xml:space="preserve">
|
||||
<value>Scaling</value>
|
||||
@@ -5047,7 +5082,7 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
|
||||
<data name="ZoomIt_Record_Format.Header" xml:space="preserve">
|
||||
<value>Format</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Record_CaptureAudio.Header" xml:space="preserve">
|
||||
<data name="ZoomIt_Record_CaptureAudio.Content" xml:space="preserve">
|
||||
<value>Capture audio input</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Record_Microphone.Header" xml:space="preserve">
|
||||
@@ -5060,10 +5095,13 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
|
||||
<value>Snip</value>
|
||||
</data>
|
||||
<data name="ZoomIt_SnipGroup.Description" xml:space="preserve">
|
||||
<value>Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file.</value>
|
||||
<value>Copy a selected area of the screen to the clipboard or to a file.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Snip_Shortcut.Header" xml:space="preserve">
|
||||
<value>Snip hotkey</value>
|
||||
<value>Snip activation</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Snip_Shortcut_Save" xml:space="preserve">
|
||||
<value>Press **{0}** to save the snip to a file instead of the clipboard.</value>
|
||||
</data>
|
||||
<data name="Oobe_ZoomIt.Description" xml:space="preserve">
|
||||
<value>ZoomIt is a screen zoom, annotation, and recording tool for technical presentations and demos. You can also use ZoomIt to snip screenshots to the clipboard or to a file.</value>
|
||||
@@ -5810,6 +5848,24 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
|
||||
<value>A modern UI built with Fluent Design</value>
|
||||
<comment>Fluent Design is a product name, do not loc</comment>
|
||||
</data>
|
||||
<data name="ZoomIt_LiveZoom_Shortcut_Draw" xml:space="preserve">
|
||||
<value>Press **{0}** to activate live drawing and **Esc** to clear annotations or to exit.
|
||||
|
||||
Press **Ctrl + Up / Down** to adjust the zoom level.
|
||||
</value>
|
||||
</data>
|
||||
<data name="ZoomIt_TypeFAQ.Text" xml:space="preserve">
|
||||
<value>Press **T** to switch to typing when drawing mode is active, and **Shift** for right-aligned text.
|
||||
Press **Esc** or **the left mouse button** to exit typing mode.
|
||||
Press **the mouse wheel** or **Up / Down** to adjust the font size.
|
||||
Text uses the current drawing color.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_DrawGroup.Description" xml:space="preserve">
|
||||
<value>Annotate the screen.</value>
|
||||
</data>
|
||||
<data name="ZoomIt_Break_BackgroundImage_None.Content" xml:space="preserve">
|
||||
<value>None</value>
|
||||
</data>
|
||||
<data name="ShowThemeAdaptiveTrayIcon.Content" xml:space="preserve">
|
||||
<value>Show a monochrome icon that matches the Windows theme</value>
|
||||
</data>
|
||||
|
||||
@@ -76,16 +76,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
GeneralSettingsConfig = settingsRepository.SettingsConfig;
|
||||
|
||||
// To obtain the settings configurations of Fancy zones.
|
||||
ArgumentNullException.ThrowIfNull(settingsRepository);
|
||||
// To obtain the settings configurations of Advanced Paste.
|
||||
ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository);
|
||||
|
||||
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
||||
|
||||
_settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils));
|
||||
|
||||
ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository);
|
||||
_advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig ?? throw new ArgumentException("SettingsConfig cannot be null", nameof(advancedPasteSettingsRepository));
|
||||
|
||||
_advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig;
|
||||
if (_advancedPasteSettings.Properties is null)
|
||||
{
|
||||
throw new ArgumentException("AdvancedPasteSettings.Properties cannot be null", nameof(advancedPasteSettingsRepository));
|
||||
}
|
||||
|
||||
// Ensure AdditionalActions and CustomActions are initialized to prevent null reference exceptions
|
||||
// This handles legacy settings files that may be missing these properties
|
||||
_advancedPasteSettings.Properties.AdditionalActions ??= new AdvancedPasteAdditionalActions();
|
||||
_advancedPasteSettings.Properties.CustomActions ??= new AdvancedPasteCustomActions();
|
||||
|
||||
AttachConfigurationHandlers();
|
||||
|
||||
@@ -93,7 +101,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
SendConfigMSG = ipcMSGCallBackFunc;
|
||||
|
||||
_additionalActions = _advancedPasteSettings.Properties.AdditionalActions;
|
||||
_customActions = _advancedPasteSettings.Properties.CustomActions.Value;
|
||||
_customActions = _advancedPasteSettings.Properties.CustomActions.Value ?? new ObservableCollection<AdvancedPasteCustomAction>();
|
||||
|
||||
SetupSettingsFileWatcher();
|
||||
|
||||
@@ -469,7 +477,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public PasteAIConfiguration PasteAIConfiguration
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PasteAIConfiguration;
|
||||
get
|
||||
{
|
||||
// Ensure PasteAIConfiguration is never null for XAML binding
|
||||
_advancedPasteSettings.Properties.PasteAIConfiguration ??= new PasteAIConfiguration();
|
||||
return _advancedPasteSettings.Properties.PasteAIConfiguration;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (!ReferenceEquals(value, _advancedPasteSettings.Properties.PasteAIConfiguration))
|
||||
|
||||
@@ -113,6 +113,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
// Null-safe access in case property wasn't upgraded yet - default to TRUE
|
||||
_cursorWrapDisableWrapDuringDrag = CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag?.Value ?? true;
|
||||
|
||||
// Null-safe access in case property wasn't upgraded yet - default to 0 (Both)
|
||||
_cursorWrapWrapMode = CursorWrapSettingsConfig.Properties.WrapMode?.Value ?? 0;
|
||||
|
||||
int isEnabled = 0;
|
||||
|
||||
Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0);
|
||||
@@ -1083,6 +1086,34 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public int CursorWrapWrapMode
|
||||
{
|
||||
get
|
||||
{
|
||||
return _cursorWrapWrapMode;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (value != _cursorWrapWrapMode)
|
||||
{
|
||||
_cursorWrapWrapMode = value;
|
||||
|
||||
// Ensure the property exists before setting value
|
||||
if (CursorWrapSettingsConfig.Properties.WrapMode == null)
|
||||
{
|
||||
CursorWrapSettingsConfig.Properties.WrapMode = new IntProperty(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
CursorWrapSettingsConfig.Properties.WrapMode.Value = value;
|
||||
}
|
||||
|
||||
NotifyCursorWrapPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCursorWrapPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
{
|
||||
OnPropertyChanged(propertyName);
|
||||
@@ -1154,5 +1185,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
private bool _isCursorWrapEnabled;
|
||||
private bool _cursorWrapAutoActivate;
|
||||
private bool _cursorWrapDisableWrapDuringDrag; // Will be initialized in constructor from settings
|
||||
private int _cursorWrapWrapMode; // 0=Both, 1=VerticalOnly, 2=HorizontalOnly
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user