mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 16:09:46 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d11b732b7 | ||
|
|
ab4792a4b8 | ||
|
|
dc3a9da968 | ||
|
|
baee472288 | ||
|
|
142833d49a | ||
|
|
46d594a7f5 | ||
|
|
673a41512c | ||
|
|
f57062c206 | ||
|
|
f339b48324 | ||
|
|
f590871d6d | ||
|
|
bdcedb142e | ||
|
|
7da85cac40 | ||
|
|
750ef385b8 | ||
|
|
7279bfd9ec | ||
|
|
8d6bb43c26 | ||
|
|
b82e3535da |
7
.github/actions/spell-check/expect.txt
vendored
7
.github/actions/spell-check/expect.txt
vendored
@@ -416,7 +416,6 @@ DISPLAYFLAGS
|
||||
DISPLAYFREQUENCY
|
||||
displayname
|
||||
DISPLAYORIENTATION
|
||||
DISPLAYPORT
|
||||
divyan
|
||||
DLGFRAME
|
||||
dlgmodalframe
|
||||
@@ -863,7 +862,6 @@ jjw
|
||||
jobject
|
||||
JOBOBJECT
|
||||
jpe
|
||||
JPN
|
||||
jpnime
|
||||
jrsoftware
|
||||
Jsons
|
||||
@@ -996,7 +994,6 @@ LTM
|
||||
LTRREADING
|
||||
luid
|
||||
lusrmgr
|
||||
LVDS
|
||||
LWA
|
||||
LWIN
|
||||
LZero
|
||||
@@ -1061,8 +1058,6 @@ MINIMIZESTART
|
||||
MINMAXINFO
|
||||
minwindef
|
||||
Mip
|
||||
Miracast
|
||||
miracast
|
||||
mkdn
|
||||
mlcfg
|
||||
mmc
|
||||
@@ -1391,6 +1386,7 @@ popups
|
||||
POPUPWINDOW
|
||||
portfile
|
||||
POSITIONITEM
|
||||
Postbot
|
||||
POWERBROADCAST
|
||||
powerdisplay
|
||||
POWERDISPLAYMODULEINTERFACE
|
||||
@@ -1821,7 +1817,6 @@ svchost
|
||||
SVGIn
|
||||
SVGIO
|
||||
svgz
|
||||
SVIDEO
|
||||
SVSI
|
||||
SWFO
|
||||
swp
|
||||
|
||||
@@ -1004,6 +1004,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/ShortcutGuide/ShortcutGuide.UnitTests/ShortcutGuide.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj" Id="e487304a-b1fb-4e6b-8e70-014051af5b99" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/Workspaces/">
|
||||
|
||||
@@ -195,10 +195,18 @@ Special sections start with an identifier enclosed between `<` and `>`. This dec
|
||||
|
||||
A string array of all the keys that need to be pressed. If a number is supplied, it should be read as a [KeyCode](https://learn.microsoft.com/windows/win32/inputdev/virtual-key-codes) and displayed accordingly (based on the Keyboard Layout of the user).
|
||||
|
||||
**Literal digit keys**:
|
||||
|
||||
Because a bare number is interpreted as a virtual-key code, a literal digit key must be authored using the `<N>` notation (the digit enclosed between `<` and `>`), where `N` is `0`–`9`. For example, `<9>` represents the literal `9` key (as in the "switch to the last tab" shortcut), not the virtual-key code `9` (which is `Tab`). The interpreter strips the brackets and displays just the digit.
|
||||
|
||||
This applies only to a single literal digit. A range such as `1 - 8` is a free-form label, not a key, and is supplied verbatim (the brackets would only be trimmed from the ends, so `<1> - <8>` would not render as intended).
|
||||
|
||||
**Special keys**:
|
||||
|
||||
Special keys are enclosed between `<` and `>` and correspond to a key that should be displayed in a certain way. If the interpreter of the manifest file can't understand the content, the brackets should be left out.
|
||||
|
||||
By convention these tokens are written as double-quoted strings in the YAML (for example `"<Enter>"` and `"<9>"`), matching the quoting used for punctuation key values. YAML treats the quoted and unquoted forms identically, so quoting is for consistency rather than a strict requirement for bracketed tokens.
|
||||
|
||||
|Name|Description|
|
||||
|----|-----------|
|
||||
|`<Office>`| Corresponds to the Office key on some Windows keyboards |
|
||||
|
||||
@@ -210,7 +210,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- SectionName: Formatting
|
||||
Properties:
|
||||
- Name: Bold
|
||||
|
||||
@@ -1542,7 +1542,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Move earlier or later by number of frames specified for stroke Duration
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -642,7 +642,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Show document template
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -810,7 +810,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 5
|
||||
- "<5>"
|
||||
- Name: Release guides
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -818,7 +818,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 5
|
||||
- "<5>"
|
||||
- Name: Show/ hide smart guides
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -925,7 +925,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 6
|
||||
- "<6>"
|
||||
- Name: Select the object above the current selection
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -965,7 +965,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Unlock a selection
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -973,7 +973,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Hide a selection
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -981,7 +981,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Show all selections
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -989,7 +989,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Move selection in user-defined increments
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1013,7 +1013,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Bring a selection forward
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1071,7 +1071,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 8
|
||||
- "<8>"
|
||||
- Name: Release a compound path
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1079,7 +1079,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 8
|
||||
- "<8>"
|
||||
- Name: Edit a pattern
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1261,7 +1261,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 4
|
||||
- "<4>"
|
||||
- Name: Move an object
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1285,7 +1285,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: Release a clipping mask
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1293,7 +1293,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: Toggle between fill and stroke
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1641,7 +1641,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 8
|
||||
- "<8>"
|
||||
- Name: Insert copyright symbol
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1665,7 +1665,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: Insert section symbol
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1673,7 +1673,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 6
|
||||
- "<6>"
|
||||
- Name: Insert trademark symbol
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1681,7 +1681,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Insert registered trademark symbol
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -1036,7 +1036,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 5
|
||||
- "<5>"
|
||||
- Name: Redraw screen
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1060,7 +1060,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Switch to next/previous document window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1155,7 +1155,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 6
|
||||
- "<6>"
|
||||
- Name: Toggle Character/Paragraph text attributes mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1163,7 +1163,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: Display the pop-up menu that has focus
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1301,7 +1301,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Show Magenta plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1309,7 +1309,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Show Yellow plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1317,7 +1317,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Show Black plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1325,7 +1325,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 4
|
||||
- "<4>"
|
||||
- Name: Show 1st Spot plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1333,7 +1333,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 5
|
||||
- "<5>"
|
||||
- Name: Show 2nd Spot plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1341,7 +1341,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 6
|
||||
- "<6>"
|
||||
- Name: Show 3rd Spot plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1349,7 +1349,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- SectionName: Transform panel
|
||||
Properties:
|
||||
- Name: Apply value and copy object
|
||||
|
||||
@@ -803,7 +803,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Switch to Hand tool (when not in text-edit mode)
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1309,7 +1309,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Tone Curve panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1317,7 +1317,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Detail panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1325,7 +1325,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: HSL/Grayscale panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1333,7 +1333,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 4
|
||||
- "<4>"
|
||||
- Name: Split Toning panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1341,7 +1341,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 5
|
||||
- "<5>"
|
||||
- Name: Lens Corrections panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1349,7 +1349,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 6
|
||||
- "<6>"
|
||||
- Name: Camera Calibration panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1357,7 +1357,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: Presets panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1365,7 +1365,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 9
|
||||
- "<9>"
|
||||
- Name: Open Snapshots panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1373,7 +1373,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 9
|
||||
- "<9>"
|
||||
- Name: Parametric Curve Targeted Adjustment tool
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1665,7 +1665,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 6
|
||||
- "<6>"
|
||||
- Name: (Filmstrip mode) Add yellow label
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1673,7 +1673,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: (Filmstrip mode) Add green label
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1681,7 +1681,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 8
|
||||
- "<8>"
|
||||
- Name: (Filmstrip mode) Add blue label
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1689,7 +1689,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 9
|
||||
- "<9>"
|
||||
- Name: (Filmstrip mode) Add purple label
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1697,7 +1697,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- Name: Camera Raw preferences
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1936,7 +1936,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- Name: Cycle through blending modes
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -2433,7 +2433,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Delete adjustment layer
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -407,7 +407,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Edge select mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -415,7 +415,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Face select mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -423,7 +423,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Extrude region
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -806,7 +806,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Set opacity to 50
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -814,7 +814,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 5
|
||||
- "<5>"
|
||||
- Name: Set opacity to 100
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -822,7 +822,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- SectionName: Arrange
|
||||
Properties:
|
||||
- Name: Bring forward
|
||||
|
||||
@@ -489,7 +489,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- SectionName: Edit
|
||||
Properties:
|
||||
- Name: Undo
|
||||
|
||||
@@ -63,7 +63,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Jump to rightmost tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -71,7 +71,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 9
|
||||
- "<9>"
|
||||
- Name: Open home page in current tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -424,7 +424,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- Name: Scroll down a screen
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -21,7 +21,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Show Intention Actions
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
@@ -778,7 +778,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Show Bookmarks window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -786,7 +786,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Show Find window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -794,7 +794,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Show Run window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -802,7 +802,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 4
|
||||
- "<4>"
|
||||
- Name: Show Debug window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -810,7 +810,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 5
|
||||
- "<5>"
|
||||
- Name: Show Problems window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -818,7 +818,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 6
|
||||
- "<6>"
|
||||
- Name: Show Structure window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -826,7 +826,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: Show Services window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -834,7 +834,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 8
|
||||
- "<8>"
|
||||
- Name: Show Version Control window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -842,7 +842,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 9
|
||||
- "<9>"
|
||||
- Name: Show Commit window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -850,7 +850,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- Name: Show Terminal window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -45,7 +45,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Switch to the last tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -53,7 +53,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 9
|
||||
- "<9>"
|
||||
- Name: Close the current tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -479,7 +479,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- Name: Stop loading page; close dialog or pop-up
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -492,7 +492,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Focus into Second Editor Group
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -500,7 +500,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Focus into Third Editor Group
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -508,7 +508,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Move Editor Left
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -202,7 +202,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- SectionName: Editing
|
||||
Properties:
|
||||
- Name: Copy
|
||||
@@ -485,7 +485,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Go to last tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -493,7 +493,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- 9
|
||||
- "<9>"
|
||||
- Name: Move tab left
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
PackageName: Postman.Postman
|
||||
Name: Postman
|
||||
WindowFilter: "Postman.exe"
|
||||
BackgroundProcess: false
|
||||
Shortcuts:
|
||||
- SectionName: Tabs
|
||||
Properties:
|
||||
- Name: Close tab
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- W
|
||||
- Name: Force close tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- W
|
||||
- Name: Switch to next tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Tab
|
||||
- Name: Switch to previous tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Tab
|
||||
- Name: Switch to tab at position (1–8)
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- '1 - 8'
|
||||
- Name: Switch to last tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<9>"
|
||||
- Name: Reopen last closed tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- T
|
||||
- Name: New runner tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- Name: Search tabs
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- SectionName: Sidebar
|
||||
Properties:
|
||||
- Name: Search sidebar
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F
|
||||
- Name: Next item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Down>"
|
||||
- Name: Previous item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Up>"
|
||||
- Name: Expand item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Right>"
|
||||
- Name: Expand all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<Right>"
|
||||
- Name: Collapse item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Left>"
|
||||
- Name: Collapse all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<Left>"
|
||||
- Name: Select item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Enter>"
|
||||
- Name: Rename item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- Name: Cut item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- Name: Copy item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- C
|
||||
- Name: Paste item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- V
|
||||
- Name: Duplicate item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- D
|
||||
- Name: Delete item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Delete>"
|
||||
- SectionName: Request
|
||||
Properties:
|
||||
- Name: Request URL
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- L
|
||||
- Name: Save request
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Save request as
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Send request
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Enter>"
|
||||
- Name: Send and download request
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<Enter>"
|
||||
- SectionName: Interface
|
||||
Properties:
|
||||
- Name: Zoom in
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Plus
|
||||
- Name: Zoom out
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Minus
|
||||
- Name: Reset zoom
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- Name: Toggle two-pane view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- V
|
||||
- Name: Toggle left sidebar
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "\\"
|
||||
- Name: Toggle right sidebar
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "\\"
|
||||
- Name: Toggle workbench
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- M
|
||||
- Name: Swap sidebars
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- S
|
||||
- Name: Reset layout
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- R
|
||||
- Name: Environment selector
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- E
|
||||
- SectionName: Window and modals
|
||||
Properties:
|
||||
- Name: New…
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: New Postman window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: New console window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- C
|
||||
- Name: Find
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- F
|
||||
- Name: Import
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- O
|
||||
- Name: Settings
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- ","
|
||||
- Name: Open shortcut help
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "/"
|
||||
- Name: Search
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Search in current workspace
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- K
|
||||
- Name: Open Postbot
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- P
|
||||
- Name: Open Vault
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- V
|
||||
- Name: Open browser tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
- Name: Cancel conversation
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- C
|
||||
- Name: Accept all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Y
|
||||
- Name: Reject all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Escape>"
|
||||
- SectionName: Console
|
||||
Properties:
|
||||
- Name: Clear console
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Show/hide console
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "`"
|
||||
@@ -241,7 +241,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Browse DMs
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -249,7 +249,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Open the Activity view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -265,7 +265,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- Name: Open the Threads view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -525,7 +525,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 9
|
||||
- "<9>"
|
||||
- Name: Inline code selected text
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -549,7 +549,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 8
|
||||
- "<8>"
|
||||
- Name: Numbered list
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -557,7 +557,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: Apply markdown formatting
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -583,7 +583,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- Name: Big heading
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -591,7 +591,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 1
|
||||
- "<1>"
|
||||
- Name: Medium heading
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -599,7 +599,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 2
|
||||
- "<2>"
|
||||
- Name: Small heading
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -607,7 +607,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- 3
|
||||
- "<3>"
|
||||
- Name: Checklist
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -615,7 +615,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 0
|
||||
- "<0>"
|
||||
- Name: Bulleted list
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -623,7 +623,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 8
|
||||
- "<8>"
|
||||
- Name: Numbered list
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -631,7 +631,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- 7
|
||||
- "<7>"
|
||||
- Name: Toggle heading and list styles
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -6,6 +6,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using ShortcutGuide.Models;
|
||||
|
||||
@@ -32,16 +33,27 @@ namespace ShortcutGuide.Helpers
|
||||
list.Add(shortcutEntry);
|
||||
}
|
||||
|
||||
// Persist on a best-effort basis. The in-memory pinned list is the source of truth
|
||||
// for the rest of the session; failing to write should not crash the overlay
|
||||
// (Pin/Unpin runs from a synchronous UI handler).
|
||||
Save();
|
||||
PinnedShortcutsChanged?.Invoke(null, appName);
|
||||
}
|
||||
|
||||
public static void Save()
|
||||
{
|
||||
string serialized = JsonSerializer.Serialize(App.PinnedShortcuts);
|
||||
|
||||
string pinnedPath = SettingsUtils.Default.GetSettingsFilePath(ShortcutGuideSettings.ModuleName, "Pinned.json");
|
||||
File.WriteAllText(pinnedPath, serialized);
|
||||
try
|
||||
{
|
||||
string serialized = JsonSerializer.Serialize(App.PinnedShortcuts);
|
||||
string pinnedPath = SettingsUtils.Default.GetSettingsFilePath(ShortcutGuideSettings.ModuleName, "Pinned.json");
|
||||
File.WriteAllText(pinnedPath, serialized);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or JsonException)
|
||||
{
|
||||
Logger.LogError("Failed to persist Shortcut Guide pinned shortcuts; keeping in-memory state.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI.Xaml;
|
||||
@@ -31,21 +34,39 @@ namespace ShortcutGuide
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
|
||||
// Register process-wide exception handlers so a stray exception (e.g. an IO failure
|
||||
// during a fire-and-forget UI handler, or a background Task fault) gets logged
|
||||
// instead of taking the overlay down with an unhandled access violation in coreclr.
|
||||
// Without these the runtime tears the process down before our local catches can run.
|
||||
this.UnhandledException += App_UnhandledException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
this.LoadData();
|
||||
MainWindow = new MainWindow();
|
||||
TaskBarWindow = new TaskbarWindow();
|
||||
MainWindow.Activate();
|
||||
MainWindow.Closed += (_, _) =>
|
||||
try
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new ShortcutGuideSessionEvent(
|
||||
MainWindow.SessionDurationMs,
|
||||
MainWindow.CloseType));
|
||||
TaskBarWindow.Close();
|
||||
};
|
||||
this.LoadData();
|
||||
MainWindow = new MainWindow();
|
||||
TaskBarWindow = new TaskbarWindow();
|
||||
MainWindow.Activate();
|
||||
MainWindow.Closed += (_, _) =>
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new ShortcutGuideSessionEvent(
|
||||
MainWindow.SessionDurationMs,
|
||||
MainWindow.CloseType));
|
||||
TaskBarWindow.Close();
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Any failure in launch is fatal for this short-lived overlay; log and exit
|
||||
// cleanly rather than letting WinUI surface a generic crash dialog.
|
||||
Logger.LogError("Failed to launch Shortcut Guide.", ex);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadData()
|
||||
@@ -63,18 +84,53 @@ namespace ShortcutGuide
|
||||
PinnedShortcuts = loaded;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
catch (Exception ex) when (ex is JsonException
|
||||
or IOException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
// Fall back to the empty default if the file is corrupt.
|
||||
// Fall back to the empty default if the file is corrupt or unreadable.
|
||||
Logger.LogWarning($"Failed to load pinned shortcuts from '{pinnedPath}'. Falling back to empty list. Reason: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
ShortcutGuideSettings = SettingsRepository<ShortcutGuideSettings>.GetInstance(settingsUtils).SettingsConfig;
|
||||
ShortcutGuideProperties = ShortcutGuideSettings.Properties;
|
||||
|
||||
try
|
||||
{
|
||||
#pragma warning disable CA1869 // Cache and reuse 'JsonSerializerOptions' instances
|
||||
settingsUtils.SaveSettings(JsonSerializer.Serialize(App.ShortcutGuideSettings, new JsonSerializerOptions { WriteIndented = true }), "Shortcut Guide");
|
||||
settingsUtils.SaveSettings(JsonSerializer.Serialize(App.ShortcutGuideSettings, new JsonSerializerOptions { WriteIndented = true }), "Shortcut Guide");
|
||||
#pragma warning restore CA1869 // Cache and reuse 'JsonSerializerOptions' instances
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Persisting the round-tripped settings is best-effort; the in-memory copy is still valid.
|
||||
Logger.LogWarning($"Failed to persist Shortcut Guide settings on launch. Reason: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
// Exceptions raised on the UI thread land here. Mark handled so the runtime
|
||||
// does not terminate the process; the overlay can usually continue.
|
||||
Logger.LogError("Unhandled UI exception in Shortcut Guide.", e.Exception);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static void CurrentDomain_UnhandledException(object sender, System.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
// Background-thread exceptions reach here as a last resort; we cannot prevent
|
||||
// termination when IsTerminating is true, but at least we leave a log trail.
|
||||
if (e.ExceptionObject is Exception ex)
|
||||
{
|
||||
Logger.LogError($"Unhandled background exception in Shortcut Guide (IsTerminating={e.IsTerminating}).", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
Logger.LogError("Unobserved Task exception in Shortcut Guide.", e.Exception);
|
||||
e.SetObserved();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,37 +237,54 @@ namespace ShortcutGuide
|
||||
|
||||
private void SetWindowPosition()
|
||||
{
|
||||
if (!this._hasMovedToRightMonitor)
|
||||
try
|
||||
{
|
||||
NativeMethods.GetCursorPos(out NativeMethods.POINT lpPoint);
|
||||
AppWindow.Move(new NativeMethods.POINT { Y = lpPoint.Y - ((int)Height / 2), X = lpPoint.X - ((int)Width / 2) });
|
||||
this._hasMovedToRightMonitor = true;
|
||||
if (!this._hasMovedToRightMonitor)
|
||||
{
|
||||
NativeMethods.GetCursorPos(out NativeMethods.POINT lpPoint);
|
||||
AppWindow.Move(new NativeMethods.POINT { Y = lpPoint.Y - ((int)Height / 2), X = lpPoint.X - ((int)Width / 2) });
|
||||
this._hasMovedToRightMonitor = true;
|
||||
}
|
||||
|
||||
var hwnd = WindowNative.GetWindowHandle(this);
|
||||
float dpi = DpiHelper.GetDPIScaleForWindow(hwnd);
|
||||
Rect monitorRect = DisplayHelper.GetWorkAreaForDisplayWithWindow(hwnd);
|
||||
|
||||
var windowPosition = (ShortcutGuideWindowPosition)App.ShortcutGuideProperties.WindowPosition.Value;
|
||||
|
||||
// App.TaskBarWindow / its AppWindow can briefly be null during the reentrant
|
||||
// Hide → Activate → BringToFront chain triggered from SelectionChanged. When the
|
||||
// taskbar window is not currently observable, skip the overlap adjustment instead
|
||||
// of crashing the overlay (issue #48448).
|
||||
var taskbarWindow = App.TaskBarWindow?.AppWindow;
|
||||
bool taskbarOnLeft = false;
|
||||
bool taskbarOnRight = false;
|
||||
if (taskbarWindow is not null)
|
||||
{
|
||||
taskbarOnLeft = taskbarWindow.IsVisible && taskbarWindow.Position.X < AppWindow.Position.X + Width && windowPosition == ShortcutGuideWindowPosition.Left;
|
||||
taskbarOnRight = taskbarWindow.IsVisible && taskbarWindow.Position.X + taskbarWindow.Size.Width > AppWindow.Position.X && windowPosition == ShortcutGuideWindowPosition.Right;
|
||||
}
|
||||
|
||||
double newHeight = monitorRect.Height / dpi;
|
||||
if (taskbarWindow is not null && (taskbarOnLeft || taskbarOnRight))
|
||||
{
|
||||
newHeight -= taskbarWindow.Size.Height;
|
||||
}
|
||||
|
||||
MaxHeight = newHeight;
|
||||
MinHeight = newHeight;
|
||||
Height = newHeight;
|
||||
|
||||
int xPosition = windowPosition == ShortcutGuideWindowPosition.Right
|
||||
? (int)(monitorRect.X + monitorRect.Width) - (int)(Width * dpi)
|
||||
: (int)monitorRect.X;
|
||||
|
||||
this.MoveAndResize(xPosition, (int)monitorRect.Y, Width, Height);
|
||||
}
|
||||
|
||||
var hwnd = WindowNative.GetWindowHandle(this);
|
||||
float dpi = DpiHelper.GetDPIScaleForWindow(hwnd);
|
||||
Rect monitorRect = DisplayHelper.GetWorkAreaForDisplayWithWindow(hwnd);
|
||||
|
||||
var windowPosition = (ShortcutGuideWindowPosition)App.ShortcutGuideProperties.WindowPosition.Value;
|
||||
var taskbarWindow = App.TaskBarWindow.AppWindow;
|
||||
bool taskbarOnLeft = taskbarWindow.IsVisible && taskbarWindow.Position.X < AppWindow.Position.X + Width && windowPosition == ShortcutGuideWindowPosition.Left;
|
||||
bool taskbarOnRight = taskbarWindow.IsVisible && taskbarWindow.Position.X + taskbarWindow.Size.Width > AppWindow.Position.X && windowPosition == ShortcutGuideWindowPosition.Right;
|
||||
|
||||
double newHeight = monitorRect.Height / dpi;
|
||||
if (taskbarOnLeft || taskbarOnRight)
|
||||
catch (Exception ex)
|
||||
{
|
||||
newHeight -= taskbarWindow.Size.Height;
|
||||
Logger.LogError("Failed to set Shortcut Guide window position; keeping previous layout.", ex);
|
||||
}
|
||||
|
||||
MaxHeight = newHeight;
|
||||
MinHeight = newHeight;
|
||||
Height = newHeight;
|
||||
|
||||
int xPosition = windowPosition == ShortcutGuideWindowPosition.Right
|
||||
? (int)(monitorRect.X + monitorRect.Width) - (int)(Width * dpi)
|
||||
: (int)monitorRect.X;
|
||||
|
||||
this.MoveAndResize(xPosition, (int)monitorRect.Y, Width, Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -282,25 +299,35 @@ namespace ShortcutGuide
|
||||
return;
|
||||
}
|
||||
|
||||
this._selectedAppName = selectedItem.Name;
|
||||
App.CurrentAppName = this._selectedAppName;
|
||||
this._shortcutFile = ManifestInterpreter.GetShortcutsOfApplication(this._selectedAppName);
|
||||
|
||||
App.TaskBarWindow.Hide();
|
||||
if (this._shortcutFile is ShortcutFile file)
|
||||
try
|
||||
{
|
||||
// Show the taskbar button window only when the selected app exposes the <TASKBAR1-9> section.
|
||||
if (file.Shortcuts is not null && file.Shortcuts.Any(c => c.SectionName?.StartsWith("<TASKBAR1-9>", StringComparison.Ordinal) == true))
|
||||
{
|
||||
this._taskBarWindowActivated = true;
|
||||
App.TaskBarWindow.Activate();
|
||||
}
|
||||
this._selectedAppName = selectedItem.Name;
|
||||
App.CurrentAppName = this._selectedAppName;
|
||||
this._shortcutFile = ManifestInterpreter.GetShortcutsOfApplication(this._selectedAppName);
|
||||
|
||||
// Reposition before navigating so the taskbar window does not clip into the main window.
|
||||
this.SetWindowPosition();
|
||||
this.ContentFrame.Navigate(
|
||||
typeof(ShortcutsPage),
|
||||
new ShortcutPageNavParam { ShortcutFile = file, AppName = this._selectedAppName });
|
||||
App.TaskBarWindow?.Hide();
|
||||
if (this._shortcutFile is ShortcutFile file)
|
||||
{
|
||||
// Show the taskbar button window only when the selected app exposes the <TASKBAR1-9> section.
|
||||
if (file.Shortcuts is not null && file.Shortcuts.Any(c => c.SectionName?.StartsWith("<TASKBAR1-9>", StringComparison.Ordinal) == true))
|
||||
{
|
||||
this._taskBarWindowActivated = true;
|
||||
App.TaskBarWindow?.Activate();
|
||||
}
|
||||
|
||||
// Reposition before navigating so the taskbar window does not clip into the main window.
|
||||
this.SetWindowPosition();
|
||||
this.ContentFrame.Navigate(
|
||||
typeof(ShortcutsPage),
|
||||
new ShortcutPageNavParam { ShortcutFile = file, AppName = this._selectedAppName });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Guard against exceptions during section navigation so the overlay does not close on the user.
|
||||
// InitializeNavItemsAsync's catch interprets any exception bubbling out of the initial
|
||||
// SelectedItem assignment as a fatal init failure and closes the window (issue #48448).
|
||||
Logger.LogError($"Failed to handle Shortcut Guide section selection '{selectedItem.Name}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,54 +30,73 @@ namespace ShortcutGuide.ShortcutGuideXAML
|
||||
|
||||
public void UpdateTasklistButtons()
|
||||
{
|
||||
// This move ensures the window spawns on the same monitor as the main window
|
||||
AppWindow.MoveInZOrderAtBottom();
|
||||
AppWindow.Move(App.MainWindow.AppWindow.Position);
|
||||
TasklistButton[] buttons = [];
|
||||
// Wrap the entire body: this method runs from the ctor and from `Activated`,
|
||||
// both of which can fire while MainWindow is closing or AppWindow is in a
|
||||
// transient null state. An exception here used to crash the overlay because
|
||||
// there was no caller-side try/catch (issue #48441).
|
||||
try
|
||||
{
|
||||
buttons = TasklistPositions.GetButtons();
|
||||
// This move ensures the window spawns on the same monitor as the main window.
|
||||
// App.MainWindow / its AppWindow can briefly be null during the reentrant
|
||||
// Hide → Activate → BringToFront chain triggered from SelectionChanged.
|
||||
var mainAppWindow = App.MainWindow?.AppWindow;
|
||||
if (mainAppWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppWindow.MoveInZOrderAtBottom();
|
||||
AppWindow.Move(mainAppWindow.Position);
|
||||
TasklistButton[] buttons = [];
|
||||
try
|
||||
{
|
||||
buttons = TasklistPositions.GetButtons();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to enumerate taskbar buttons via TasklistPositions.GetButtons.", ex);
|
||||
}
|
||||
|
||||
if (buttons.Length == 0)
|
||||
{
|
||||
AppWindow.Hide();
|
||||
return;
|
||||
}
|
||||
|
||||
float dpi = this.DPI;
|
||||
double windowsLogoColumnWidth = this.WindowsLogoColumnWidth.Width.Value;
|
||||
double windowHeight = 58;
|
||||
double windowMargin = 8 * dpi;
|
||||
double windowWidth = windowsLogoColumnWidth;
|
||||
double xPosition = buttons[0].X - (windowsLogoColumnWidth * dpi);
|
||||
double yPosition = this.WorkArea.Bottom - (windowHeight * dpi);
|
||||
|
||||
this.KeyHolder.Children.Clear();
|
||||
|
||||
foreach (TasklistButton b in buttons)
|
||||
{
|
||||
TaskbarIndicator indicator = new()
|
||||
{
|
||||
Label = b.Keynum >= 10 ? "0" : b.Keynum.ToString(CultureInfo.InvariantCulture),
|
||||
Height = b.Height / dpi,
|
||||
Width = b.Width / dpi,
|
||||
};
|
||||
|
||||
windowWidth += indicator.Width;
|
||||
|
||||
this.KeyHolder.Children.Add(indicator);
|
||||
|
||||
double indicatorPos = (b.X - xPosition) / dpi;
|
||||
Canvas.SetLeft(indicator, indicatorPos - windowsLogoColumnWidth);
|
||||
}
|
||||
|
||||
this.MoveAndResize(xPosition - windowMargin, yPosition, windowWidth + (2 * windowMargin), windowHeight);
|
||||
AppWindow.MoveInZOrderAtTop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to enumerate taskbar buttons via TasklistPositions.GetButtons.", ex);
|
||||
Logger.LogError("Failed to update Shortcut Guide taskbar indicator window.", ex);
|
||||
}
|
||||
|
||||
if (buttons.Length == 0)
|
||||
{
|
||||
AppWindow.Hide();
|
||||
return;
|
||||
}
|
||||
|
||||
float dpi = this.DPI;
|
||||
double windowsLogoColumnWidth = this.WindowsLogoColumnWidth.Width.Value;
|
||||
double windowHeight = 58;
|
||||
double windowMargin = 8 * dpi;
|
||||
double windowWidth = windowsLogoColumnWidth;
|
||||
double xPosition = buttons[0].X - (windowsLogoColumnWidth * dpi);
|
||||
double yPosition = this.WorkArea.Bottom - (windowHeight * dpi);
|
||||
|
||||
this.KeyHolder.Children.Clear();
|
||||
|
||||
foreach (TasklistButton b in buttons)
|
||||
{
|
||||
TaskbarIndicator indicator = new()
|
||||
{
|
||||
Label = b.Keynum >= 10 ? "0" : b.Keynum.ToString(CultureInfo.InvariantCulture),
|
||||
Height = b.Height / dpi,
|
||||
Width = b.Width / dpi,
|
||||
};
|
||||
|
||||
windowWidth += indicator.Width;
|
||||
|
||||
this.KeyHolder.Children.Add(indicator);
|
||||
|
||||
double indicatorPos = (b.X - xPosition) / dpi;
|
||||
Canvas.SetLeft(indicator, indicatorPos - windowsLogoColumnWidth);
|
||||
}
|
||||
|
||||
this.MoveAndResize(xPosition - windowMargin, yPosition, windowWidth + (2 * windowMargin), windowHeight);
|
||||
AppWindow.MoveInZOrderAtTop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using ShortcutGuide.Converters;
|
||||
using ShortcutGuide.Models;
|
||||
|
||||
namespace ShortcutGuide.UnitTests.ConvertersTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ShortcutDescriptionToKeysConverterTests
|
||||
{
|
||||
private static List<object> Convert(ShortcutDescription description)
|
||||
=> new ShortcutDescriptionToKeysConverter().GetKeysList(description);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<0>")]
|
||||
[DataRow("<1>")]
|
||||
[DataRow("<8>")]
|
||||
[DataRow("<9>")]
|
||||
public void GetKeysList_LiteralDigitKey_IsPassedThroughVerbatim(string key)
|
||||
{
|
||||
// A literal digit key (e.g. Ctrl+9 "switch to last tab") is authored with the
|
||||
// <N> convention so it is not parsed as a virtual-key code (VK 9 is Tab, VK 1 is
|
||||
// the left mouse button, VK 0 is undefined). The converter forwards the token
|
||||
// unchanged; KeyVisual strips the angle brackets when rendering.
|
||||
var result = Convert(new ShortcutDescription(ctrl: true, shift: false, alt: false, win: false, keys: [key]));
|
||||
|
||||
CollectionAssert.AreEqual(new object[] { "Ctrl", key }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetKeysList_Modifiers_AreEmittedBeforeKeysInWinCtrlAltShiftOrder()
|
||||
{
|
||||
// Win -> 92, Ctrl -> "Ctrl", Alt -> "Alt", Shift -> 16, then the keys.
|
||||
var result = Convert(new ShortcutDescription(ctrl: true, shift: true, alt: true, win: true, keys: ["A"]));
|
||||
|
||||
CollectionAssert.AreEqual(new object[] { 92, "Ctrl", "Alt", 16, "A" }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetKeysList_NonNumericKey_IsPassedThroughVerbatim()
|
||||
{
|
||||
// Non-numeric key strings (e.g. the "1 - 8" tab-range) render as-is.
|
||||
var result = Convert(new ShortcutDescription(ctrl: true, shift: false, alt: false, win: false, keys: ["1 - 8"]));
|
||||
|
||||
CollectionAssert.AreEqual(new object[] { "Ctrl", "1 - 8" }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetKeysList_ArrowNameKey_MapsToVirtualKeyCode()
|
||||
{
|
||||
// Named arrow keys map to their VK codes (Up -> 38), independent of the digit handling.
|
||||
var result = Convert(new ShortcutDescription(ctrl: false, shift: false, alt: false, win: false, keys: ["Up"]));
|
||||
|
||||
CollectionAssert.AreEqual(new object[] { 38 }, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier>
|
||||
<RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\ShortcutGuide.UnitTests\</OutputPath>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ShortcutGuide.Ui\ShortcutGuide.Ui.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1089,13 +1089,10 @@ VideoRecordingSession::VideoRecordingSession(
|
||||
// Store frame interval for timeout-based frame production when webcam is active.
|
||||
m_frameIntervalTicks = ( frameRate > 0 ) ? ( 10'000'000LL / frameRate ) : 333'333LL;
|
||||
|
||||
if (captureAudio || captureSystemAudio)
|
||||
{
|
||||
// Always set up audio profile for loopback capture (stereo AAC)
|
||||
auto audio = m_encodingProfile.Audio();
|
||||
audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000);
|
||||
m_encodingProfile.Audio(audio);
|
||||
}
|
||||
// NOTE: Audio encoding profile (m_encodingProfile.Audio) is set in
|
||||
// StartAsync() after the audio graph is fully initialized, not here.
|
||||
// Calling GetEncodingProperties() before InitializeAsync completes
|
||||
// would crash because m_audioOutputNode is still null.
|
||||
|
||||
// Describe our input: uncompressed BGRA8 buffers
|
||||
auto properties = winrt::VideoEncodingProperties::CreateUncompressed(
|
||||
@@ -1176,7 +1173,16 @@ winrt::IAsyncAction VideoRecordingSession::StartAsync()
|
||||
RecDiag( L"StartAsync: co_await InitializeAsync...\n" );
|
||||
co_await m_audioGenerator->InitializeAsync();
|
||||
RecDiag( L"StartAsync: audio initialized\n" );
|
||||
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(m_audioGenerator->GetEncodingProperties()));
|
||||
|
||||
// Set up the audio encoding profile now that the audio graph is
|
||||
// fully initialized. GetEncodingProperties() requires
|
||||
// m_audioOutputNode to be valid, which is only guaranteed after
|
||||
// InitializeAsync completes.
|
||||
auto audioProps = m_audioGenerator->GetEncodingProperties();
|
||||
m_encodingProfile.Audio(winrt::AudioEncodingProperties::CreateAac(
|
||||
audioProps.SampleRate(), audioProps.ChannelCount(), 192000));
|
||||
|
||||
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(audioProps));
|
||||
}
|
||||
else {
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
@@ -20,6 +19,9 @@ public record AppStateModel
|
||||
init => _recentCommands = value;
|
||||
}
|
||||
|
||||
// HERE BE DRAGONS: Using an ImmutableList<T> for a setting may explode in
|
||||
// AOT builds. Make sure to test IN AOT setting this setting to null, [],
|
||||
// and and array with values.
|
||||
private ImmutableList<string>? _runHistory = ImmutableList<string>.Empty;
|
||||
|
||||
public ImmutableList<string> RunHistory
|
||||
|
||||
@@ -427,12 +427,39 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
|
||||
var title = _commandItemViewModel?.Title ?? string.Empty;
|
||||
var subtitle = _commandItemViewModel?.Subtitle ?? string.Empty;
|
||||
var icon = _commandItemViewModel?.Icon;
|
||||
var dockSide = _settingsService.Settings.DockSettings.Side;
|
||||
IReadOnlyList<MonitorInfo>? monitors = _monitorService?.GetMonitors();
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var dockSide = dockSettings.Side;
|
||||
IReadOnlyList<MonitorInfo>? monitors = GetDockEnabledMonitors(_monitorService, dockSettings);
|
||||
ShowPinToDockDialogMessage message = new(_providerId, _commandId, title, subtitle, icon, dockSide, monitors);
|
||||
WeakReferenceMessenger.Default.Send(message);
|
||||
}
|
||||
|
||||
// Only list monitors where the dock is currently enabled, so users can't
|
||||
// pin a command to a display that has no dock visible.
|
||||
private static IReadOnlyList<MonitorInfo>? GetDockEnabledMonitors(IMonitorService? monitorService, DockSettings dockSettings)
|
||||
{
|
||||
var monitors = monitorService?.GetMonitors();
|
||||
if (monitors is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var configs = dockSettings.MonitorConfigs;
|
||||
|
||||
// When there are no per-monitor configs (legacy / first-run), the dock
|
||||
// is only shown on the primary monitor.
|
||||
if (configs.Count == 0)
|
||||
{
|
||||
return monitors.Where(m => m.IsPrimary).ToList();
|
||||
}
|
||||
|
||||
return monitors
|
||||
.Where(m => configs.Any(c =>
|
||||
string.Equals(c.MonitorDeviceId, m.StableId, System.StringComparison.OrdinalIgnoreCase) &&
|
||||
c.Enabled))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void UnpinFromDock()
|
||||
{
|
||||
PinToDockMessage message = new(_providerId, _commandId, false);
|
||||
|
||||
@@ -24,9 +24,21 @@ internal sealed class RunHistoryService : IRunHistoryService
|
||||
if (_appStateService.State.RunHistory.IsEmpty)
|
||||
{
|
||||
var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory();
|
||||
|
||||
// Copy the WinRT-projected IVector<string> into a plain List<string>
|
||||
// before building the ImmutableList. ImmutableList.CreateRange tries to
|
||||
// cast the source to IReadOnlyCollection<string>, which requires a WinRT
|
||||
// helper type that isn't available in AOT builds and throws
|
||||
// NotSupportedException.
|
||||
var historyList = new List<string>(history.Count);
|
||||
for (var i = 0; i < history.Count; i++)
|
||||
{
|
||||
historyList.Add(history[i]);
|
||||
}
|
||||
|
||||
_appStateService.UpdateState(state => state with
|
||||
{
|
||||
RunHistory = history.ToImmutableList(),
|
||||
RunHistory = historyList.ToImmutableList(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,25 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
|
||||
if (isBandPage)
|
||||
{
|
||||
_networkUpItem = new ListItem(_networkPage)
|
||||
{
|
||||
Title = $"{_networkUpSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
|
||||
Icon = Icons.NetworkUpIcon,
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
|
||||
_networkDownItem = new ListItem(_networkPage)
|
||||
{
|
||||
Title = $"{_networkDownSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
|
||||
Icon = Icons.NetworkDownIcon,
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
}
|
||||
|
||||
_networkPage.Updated += (s, e) =>
|
||||
{
|
||||
_networkItem.Title = _networkPage.GetItemTitle(isBandPage);
|
||||
@@ -253,22 +272,6 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
}
|
||||
else
|
||||
{
|
||||
_networkUpItem = new ListItem(_networkPage!)
|
||||
{
|
||||
Title = $"{_networkUpSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
|
||||
Icon = Icons.NetworkUpIcon,
|
||||
MoreCommands = _networkPage!.Commands,
|
||||
};
|
||||
|
||||
_networkDownItem = new ListItem(_networkPage!)
|
||||
{
|
||||
Title = $"{_networkDownSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
|
||||
Icon = Icons.NetworkDownIcon,
|
||||
MoreCommands = _networkPage!.Commands,
|
||||
};
|
||||
|
||||
return _batteryItem is not null
|
||||
? new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem!, _batteryItem! }
|
||||
: new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem! };
|
||||
|
||||
@@ -94,7 +94,7 @@ internal static class Commands
|
||||
})
|
||||
{
|
||||
Title = Resources.Microsoft_plugin_sys_hibernate,
|
||||
Icon = Icons.SleepIcon, // Icon change needed
|
||||
Icon = Icons.HibernateIcon,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -25,4 +25,6 @@ internal sealed class Icons
|
||||
internal static IconInfo ShutdownIcon { get; } = new IconInfo("\uE7E8");
|
||||
|
||||
internal static IconInfo SleepIcon { get; } = new IconInfo("\uE708");
|
||||
|
||||
internal static IconInfo HibernateIcon { get; } = new IconInfo("\uE823");
|
||||
}
|
||||
|
||||
@@ -243,5 +243,7 @@ namespace ColorPicker.Helpers
|
||||
lpPoint.Y += yOffset;
|
||||
SetCursorPos(lpPoint.X, lpPoint.Y);
|
||||
}
|
||||
|
||||
internal IntPtr GetMainWindowHandle() => _hwndSource?.Handle ?? IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ManagedCommon;
|
||||
|
||||
namespace ColorPicker.Helpers;
|
||||
|
||||
internal static class WindowCaptureExclusionHelper
|
||||
{
|
||||
// Windows 10 version 2004 (build 19041) is the minimum supported version. PowerToys
|
||||
// itself requires the same version, so this check is not strictly required, but is
|
||||
// useful as a safeguard.
|
||||
private static readonly bool IsSupported =
|
||||
Environment.OSVersion.Version >= new Version(10, 0, 19041);
|
||||
|
||||
// Only logging once per session to avoid repeated identical warnings, as the zoom
|
||||
// window may be used very often.
|
||||
private static bool hasLoggedFailure;
|
||||
|
||||
internal static bool Exclude(IntPtr hwnd) =>
|
||||
SetWindowAffinity(hwnd, NativeMethods.WDA_EXCLUDEFROMCAPTURE);
|
||||
|
||||
internal static bool Include(IntPtr hwnd) =>
|
||||
SetWindowAffinity(hwnd, NativeMethods.WDA_NONE);
|
||||
|
||||
private static bool SetWindowAffinity(nint hwnd, uint affinity)
|
||||
{
|
||||
if (!IsSupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = NativeMethods.SetWindowDisplayAffinity(hwnd, affinity);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
int errorCode = Marshal.GetLastWin32Error();
|
||||
if (!hasLoggedFailure)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
$"Failed to set window display affinity. Error code: {errorCode}");
|
||||
hasLoggedFailure = true;
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
@@ -79,12 +79,30 @@ namespace ColorPicker.Helpers
|
||||
// we just started zooming, copy screen area
|
||||
if (_previousZoomLevel == 0)
|
||||
{
|
||||
var x = (int)point.X - (BaseZoomImageSize / 2);
|
||||
var y = (int)point.Y - (BaseZoomImageSize / 2);
|
||||
// First, exclude the color picker window from the capture; otherwise its
|
||||
// corner will be included in the zoomed-in image.
|
||||
var mainWindowHandle = _appStateHandler.GetMainWindowHandle();
|
||||
bool exclusionSuccess =
|
||||
WindowCaptureExclusionHelper.Exclude(mainWindowHandle);
|
||||
|
||||
_graphics.CopyFromScreen(x, y, 0, 0, _bmp.Size, CopyPixelOperation.SourceCopy);
|
||||
try
|
||||
{
|
||||
var x = (int)point.X - (BaseZoomImageSize / 2);
|
||||
var y = (int)point.Y - (BaseZoomImageSize / 2);
|
||||
|
||||
_zoomViewModel.ZoomArea = BitmapToImageSource(_bmp);
|
||||
_graphics.CopyFromScreen(x, y, 0, 0, _bmp.Size, CopyPixelOperation.SourceCopy);
|
||||
|
||||
_zoomViewModel.ZoomArea = BitmapToImageSource(_bmp);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore the color picker window to normal display affinity so that
|
||||
// it can be captured again.
|
||||
if (exclusionSuccess)
|
||||
{
|
||||
WindowCaptureExclusionHelper.Include(mainWindowHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_zoomViewModel.ZoomFactor = Math.Pow(ZoomFactor, _currentZoomLevel - 1);
|
||||
|
||||
@@ -231,5 +231,17 @@ namespace ColorPicker
|
||||
var hwnd = new WindowInteropHelper(win).Handle;
|
||||
_ = SetWindowLong(hwnd, GWL_EX_STYLE, GetWindowLong(hwnd, GWL_EX_STYLE) | WS_EX_TOOLWINDOW);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the display affinity of a window, which controls how the window is
|
||||
/// displayed on a monitor. Used to exclude the picker window from ZoomWindow's
|
||||
/// source bitmap.
|
||||
/// </summary>
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint dwAffinity);
|
||||
|
||||
internal const uint WDA_NONE = 0x00000000;
|
||||
internal const uint WDA_EXCLUDEFROMCAPTURE = 0x00000011;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,14 @@ namespace KeyboardEventHandlers
|
||||
if (data->wParam == WM_KEYDOWN || data->wParam == WM_SYSKEYDOWN)
|
||||
{
|
||||
ResetIfModifierKeyForLowerLevelKeyHandlers(ii, it->first, target);
|
||||
|
||||
// If a Ctrl/Alt/Shift key is remapped to a non-modifier key, reset the modifier state to prevent the injected key from being delivered as WM_SYSKEYDOWN instead of WM_KEYDOWN
|
||||
if (Helpers::IsModifierKey(it->first) && !Helpers::IsModifierKey(target) && target != VK_CAPITAL && !(it->first == VK_LWIN || it->first == VK_RWIN || it->first == CommonSharedConstants::VK_WIN_BOTH))
|
||||
{
|
||||
std::vector<INPUT> suppressList;
|
||||
Helpers::SetKeyEvent(suppressList, INPUT_KEYBOARD, static_cast<WORD>(it->first), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG);
|
||||
ii.SendVirtualInput(suppressList);
|
||||
}
|
||||
}
|
||||
|
||||
if (remapToKey)
|
||||
|
||||
@@ -226,6 +226,27 @@ namespace RemappingLogicTests
|
||||
Assert::AreEqual(1, mockedInputHandler.GetSendVirtualInputCallCount());
|
||||
}
|
||||
|
||||
// Test if SendVirtualInput is sent exactly once with the suppress flag when a Ctrl/Alt/Shift key is remapped to a non-modifier key
|
||||
TEST_METHOD (HandleSingleKeyRemapEvent_ShouldSendVirtualInputWithSuppressFlagExactlyOnce_WhenCtrlAltShiftIsMappedToNonModifierKey)
|
||||
{
|
||||
mockedInputHandler.SetSendVirtualInputTestHandler([](LowlevelKeyboardEvent* data) {
|
||||
if (data->lParam->dwExtraInfo == KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
});
|
||||
|
||||
testState.AddSingleKeyRemap(VK_LMENU, (DWORD)VK_BACK);
|
||||
|
||||
std::vector<INPUT> inputs{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = VK_LMENU } },
|
||||
};
|
||||
|
||||
mockedInputHandler.SendVirtualInput(inputs);
|
||||
|
||||
Assert::AreEqual(1, mockedInputHandler.GetSendVirtualInputCallCount());
|
||||
}
|
||||
|
||||
// Test if correct keyboard states are set for a single key to two key shortcut remap
|
||||
TEST_METHOD (RemappedKeyToTwoKeyShortcut_ShouldSetTargetKeyState_OnKeyEvent)
|
||||
{
|
||||
|
||||
@@ -22,6 +22,8 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
|
||||
|
||||
public string AppData { get; set; } = string.Empty;
|
||||
|
||||
public string SharedStorageDbPath { get; set; } = string.Empty;
|
||||
|
||||
public ImageSource WorkspaceIcon() => WorkspaceIconBitMap;
|
||||
|
||||
public ImageSource RemoteIcon() => RemoteIconBitMap;
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
|
||||
public static class VSCodeInstances
|
||||
{
|
||||
private static readonly string _userAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
private static readonly string _userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
public static List<VSCodeInstance> Instances { get; set; } = new List<VSCodeInstance>();
|
||||
|
||||
@@ -129,6 +130,7 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
|
||||
|
||||
var portableData = Path.Join(iconPath, "data");
|
||||
instance.AppData = Directory.Exists(portableData) ? Path.Join(portableData, "user-data") : Path.Combine(_userAppDataPath, version);
|
||||
instance.SharedStorageDbPath = GetSharedStorageDbPath(version, iconPath, Directory.Exists(portableData));
|
||||
var vsCodeIconPath = Path.Join(iconPath, $"{version}.exe");
|
||||
if (!File.Exists(vsCodeIconPath))
|
||||
{
|
||||
@@ -157,5 +159,30 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
|
||||
Instances.Add(instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSharedStorageDbPath(string version, string iconPath, bool isPortable)
|
||||
{
|
||||
if (isPortable)
|
||||
{
|
||||
return Path.Join(iconPath, "data-shared", "sharedStorage", "state.vscdb");
|
||||
}
|
||||
|
||||
var sharedStorageDirectory = version switch
|
||||
{
|
||||
"Code" => ".vscode-shared",
|
||||
"Code - Insiders" => ".vscode-insiders-shared",
|
||||
"Code - Exploration" => ".vscode-exploration-shared",
|
||||
"VSCodium" => ".vscodium-shared",
|
||||
"VSCodium - Insiders" => ".vscodium-insiders-shared",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(sharedStorageDirectory))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Path.Combine(_userProfilePath, sharedStorageDirectory, "sharedStorage", "state.vscdb");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
|
||||
|
||||
// User/globalStorage/state.vscdb - history.recentlyOpenedPathsList - vscode v1.64 or later
|
||||
var vscode_storage_db = Path.Combine(vscodeInstance.AppData, "User/globalStorage/state.vscdb");
|
||||
var vscode_shared_storage_db = vscodeInstance.SharedStorageDbPath;
|
||||
|
||||
if (File.Exists(vscode_storage))
|
||||
{
|
||||
@@ -104,17 +105,37 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
|
||||
results.AddRange(storageResults);
|
||||
}
|
||||
|
||||
if (File.Exists(vscode_storage_db))
|
||||
var storageDbPaths = new[] { vscode_storage_db, vscode_shared_storage_db }
|
||||
.Where(filePath => !string.IsNullOrEmpty(filePath))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var storageDbPath in storageDbPaths)
|
||||
{
|
||||
var storageDbResults = GetWorkspacesInVscdb(vscodeInstance, vscode_storage_db);
|
||||
results.AddRange(storageDbResults);
|
||||
if (File.Exists(storageDbPath))
|
||||
{
|
||||
var storageDbResults = GetWorkspacesInVscdb(vscodeInstance, storageDbPath);
|
||||
results.AddRange(storageDbResults);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
return results
|
||||
.Where(workspace => workspace != null)
|
||||
.GroupBy(GetWorkspaceKey, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(workspaceGroup => workspaceGroup.First())
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetWorkspaceKey(VSCodeWorkspace workspace)
|
||||
{
|
||||
return string.Join(
|
||||
"|",
|
||||
workspace.VSCodeInstance?.ExecutablePath ?? string.Empty,
|
||||
workspace.WorkspaceType,
|
||||
workspace.Path ?? string.Empty);
|
||||
}
|
||||
|
||||
private List<VSCodeWorkspace> GetWorkspacesInJson(VSCodeInstance vscodeInstance, string filePath)
|
||||
{
|
||||
var storageFileResults = new List<VSCodeWorkspace>();
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Drivers;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class DisplayClassifierTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
|
||||
// Internal: INTERNAL high-bit flag
|
||||
[DataRow(0x80000000u, true, DisplayName = "INTERNAL bit only")]
|
||||
[DataRow(0x8000000Bu, true, DisplayName = "INTERNAL | DISPLAYPORT_EMBEDDED")]
|
||||
|
||||
// Internal: documented embedded subtypes
|
||||
[DataRow(11u, true, DisplayName = "DISPLAYPORT_EMBEDDED")]
|
||||
[DataRow(13u, true, DisplayName = "UDI_EMBEDDED")]
|
||||
|
||||
// External: LVDS is not classified internal per docs
|
||||
[DataRow(6u, false, DisplayName = "LVDS (not classified internal per docs)")]
|
||||
|
||||
// External: documented external connectors
|
||||
[DataRow(5u, false, DisplayName = "HDMI")]
|
||||
[DataRow(10u, false, DisplayName = "DISPLAYPORT_EXTERNAL")]
|
||||
[DataRow(12u, false, DisplayName = "UDI_EXTERNAL")]
|
||||
|
||||
// External: virtual / wireless
|
||||
[DataRow(15u, false, DisplayName = "MIRACAST")]
|
||||
[DataRow(17u, false, DisplayName = "INDIRECT_VIRTUAL")]
|
||||
|
||||
// External: OTHER (-1) cast to uint
|
||||
[DataRow(0xFFFFFFFFu, false, DisplayName = "OTHER (-1 cast to uint)")]
|
||||
|
||||
// External: unrecognized values default to external
|
||||
[DataRow(0xDEADBEEFu, false, DisplayName = "Unknown value defaults to external")]
|
||||
|
||||
// External: INTERNAL flag combined with an undocumented subtype is treated as external
|
||||
// (locks in the docstring's "INTERNAL | unknown subtype = external" rule).
|
||||
[DataRow(0x80000007u, false, DisplayName = "INTERNAL | unknown subtype 7 (treated as external)")]
|
||||
public void IsInternal_ReturnsExpectedClassification(uint outputTechnology, bool expected)
|
||||
{
|
||||
Assert.AreEqual(expected, DisplayClassifier.IsInternal(outputTechnology));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Models;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class MonitorIdComparerTests
|
||||
{
|
||||
private const string Upper = @"\\?\DISPLAY#BOE0900#4&ABC&0&UID111";
|
||||
private const string Lower = @"\\?\display#boe0900#4&abc&0&uid111";
|
||||
private const string DifferentUid = @"\\?\DISPLAY#BOE0900#4&ABC&0&UID222";
|
||||
|
||||
[TestMethod]
|
||||
public void Equal_IdsDifferingOnlyByCase_AreEqual()
|
||||
{
|
||||
Assert.IsTrue(MonitorIdComparer.Equal(Upper, Lower));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equal_DistinctMonitors_AreNotEqual()
|
||||
{
|
||||
Assert.IsFalse(MonitorIdComparer.Equal(Upper, DifferentUid));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equal_BothNull_AreEqual()
|
||||
{
|
||||
Assert.IsTrue(MonitorIdComparer.Equal(null, null));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equal_NullVersusValue_AreNotEqual()
|
||||
{
|
||||
Assert.IsFalse(MonitorIdComparer.Equal(null, Upper));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Instance_IsCaseInsensitive_ForDictionaryKeys()
|
||||
{
|
||||
var set = new System.Collections.Generic.HashSet<string>(MonitorIdComparer.Instance) { Upper };
|
||||
|
||||
Assert.IsTrue(set.Contains(Lower), "A monitor-Id-keyed set must match regardless of casing");
|
||||
Assert.IsFalse(set.Contains(DifferentUid));
|
||||
}
|
||||
}
|
||||
@@ -39,77 +39,78 @@ public class MonitorIdentityTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PnpHardwareKeyFromDevicePath_ReturnsHardwareSegments()
|
||||
{
|
||||
var input = @"\\?\DISPLAY#BOE0900#4&40f4dee&0&UID8388688#{e6f07b5f-ee97-4a90-b076-33f57bf4eaa7}";
|
||||
var expected = @"BOE0900#4&40f4dee&0&UID8388688";
|
||||
|
||||
Assert.AreEqual(expected, MonitorIdentity.PnpHardwareKeyFromDevicePath(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PnpHardwareKeyFromInstanceName_StripsSuffixAndNormalizesSeparator()
|
||||
public void FromInstanceName_StripsSuffixNormalizesSeparatorAndAddsPrefix()
|
||||
{
|
||||
var input = @"DISPLAY\BOE0900\4&40f4dee&0&UID8388688_0";
|
||||
var expected = @"BOE0900#4&40f4dee&0&UID8388688";
|
||||
var expected = @"\\?\DISPLAY#BOE0900#4&40f4dee&0&UID8388688";
|
||||
|
||||
Assert.AreEqual(expected, MonitorIdentity.PnpHardwareKeyFromInstanceName(input));
|
||||
Assert.AreEqual(expected, MonitorIdentity.FromInstanceName(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PnpHardwareKey_CrossFormat_ProducesSameKey()
|
||||
public void FromInstanceName_MatchesFromDevicePath_ForSamePhysicalMonitor()
|
||||
{
|
||||
// The whole point of the PnP key: a WMI InstanceName and the matching DevicePath
|
||||
// for the same physical monitor must produce identical keys, so WMI brightness
|
||||
// instances can be joined to QueryDisplayConfig targets with a single lookup —
|
||||
// even on dual-internal-panel devices (Yoga Book 9i, Zenbook Duo) where the
|
||||
// EdidId alone collides.
|
||||
// The core invariant of the WMI<->QueryDisplayConfig join: a WMI InstanceName and
|
||||
// the matching DevicePath for the same physical monitor must reduce to the identical
|
||||
// Monitor.Id, so WMI brightness instances can be paired with inventory entries with a
|
||||
// single lookup — even on dual-internal-panel devices (Yoga Book 9i, Zenbook Duo)
|
||||
// where the EdidId alone collides.
|
||||
var instanceName = @"DISPLAY\BOE0900\4&40f4dee&0&UID8388688_0";
|
||||
var devicePath = @"\\?\DISPLAY#BOE0900#4&40f4dee&0&UID8388688#{e6f07b5f-ee97-4a90-b076-33f57bf4eaa7}";
|
||||
|
||||
var keyFromInstance = MonitorIdentity.PnpHardwareKeyFromInstanceName(instanceName);
|
||||
var keyFromDevicePath = MonitorIdentity.PnpHardwareKeyFromDevicePath(devicePath);
|
||||
var idFromInstance = MonitorIdentity.FromInstanceName(instanceName);
|
||||
|
||||
Assert.AreEqual(keyFromInstance, keyFromDevicePath);
|
||||
Assert.IsFalse(string.IsNullOrEmpty(keyFromInstance), "expected non-empty key");
|
||||
Assert.AreEqual(MonitorIdentity.FromDevicePath(devicePath), idFromInstance);
|
||||
Assert.IsFalse(string.IsNullOrEmpty(idFromInstance), "expected non-empty id");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PnpHardwareKey_DualInternalPanel_DistinguishesByUid()
|
||||
public void FromInstanceName_DiscreteGpuExternalReportedPanel_StillMatchesInventory()
|
||||
{
|
||||
// Issue #48587: on a dual-GPU laptop the built-in panel driven by the discrete GPU is
|
||||
// reported by QueryDisplayConfig as DisplayPort-External, yet WmiMonitorBrightness
|
||||
// still exposes it. The InstanceName and DevicePath captured in that state must reduce
|
||||
// to the same Monitor.Id so WMI can claim the panel and brightness control keeps working.
|
||||
var instanceName = @"DISPLAY\BOE0D79\5&1abcdef7&0&UID4352_0";
|
||||
var devicePath = @"\\?\DISPLAY#BOE0D79#5&1abcdef7&0&UID4352#{e6f07b5f-ee97-4a90-b076-33f57bf4eaa7}";
|
||||
|
||||
Assert.AreEqual(
|
||||
MonitorIdentity.FromDevicePath(devicePath),
|
||||
MonitorIdentity.FromInstanceName(instanceName));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromInstanceName_DualInternalPanel_DistinguishesByUid()
|
||||
{
|
||||
// Yoga Book 9i style: two identical internal panels (same EdidId BOE0900) with
|
||||
// different PnP UIDs. The PnP key must differ so the two WMI brightness instances
|
||||
// each pair with the correct MonitorDisplayInfo.
|
||||
// different PnP UIDs must reduce to different Monitor.Ids so the two WMI brightness
|
||||
// instances each pair with the correct inventory entry.
|
||||
var panelA = @"DISPLAY\BOE0900\4&abcdef&0&UID111_0";
|
||||
var panelB = @"DISPLAY\BOE0900\4&abcdef&0&UID222_0";
|
||||
|
||||
Assert.AreNotEqual(
|
||||
MonitorIdentity.PnpHardwareKeyFromInstanceName(panelA),
|
||||
MonitorIdentity.PnpHardwareKeyFromInstanceName(panelB));
|
||||
MonitorIdentity.FromInstanceName(panelA),
|
||||
MonitorIdentity.FromInstanceName(panelB));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PnpHardwareKeyFromInstanceName_MultiDigitSuffix_StrippedCorrectly()
|
||||
public void FromInstanceName_MultiDigitSuffix_StrippedCorrectly()
|
||||
{
|
||||
// WMI instance suffix can be _0, _1, _10, etc. — LastIndexOf('_') ensures we
|
||||
// strip only the trailing suffix, not an underscore inside the UID itself.
|
||||
// WMI instance suffix can be _0, _1, _10, etc. — LastIndexOf('_') ensures we strip
|
||||
// only the trailing suffix, not an underscore inside the UID itself.
|
||||
var input = @"DISPLAY\BOE0900\4&40f4dee&0&UID8388688_12";
|
||||
var expected = @"BOE0900#4&40f4dee&0&UID8388688";
|
||||
var expected = @"\\?\DISPLAY#BOE0900#4&40f4dee&0&UID8388688";
|
||||
|
||||
Assert.AreEqual(expected, MonitorIdentity.PnpHardwareKeyFromInstanceName(input));
|
||||
Assert.AreEqual(expected, MonitorIdentity.FromInstanceName(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PnpHardwareKey_NullEmptyOrMalformed_ReturnsEmpty()
|
||||
public void FromInstanceName_NullEmptyOrMalformed_ReturnsEmpty()
|
||||
{
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.PnpHardwareKeyFromDevicePath(null));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.PnpHardwareKeyFromDevicePath(string.Empty));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.PnpHardwareKeyFromDevicePath(@"\\?\DISPLAY"));
|
||||
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.PnpHardwareKeyFromInstanceName(null));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.PnpHardwareKeyFromInstanceName(string.Empty));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.PnpHardwareKeyFromInstanceName(@"DISPLAY"));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.PnpHardwareKeyFromInstanceName(@"DISPLAY\BOE0900"));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.FromInstanceName(null));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.FromInstanceName(string.Empty));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.FromInstanceName(@"DISPLAY"));
|
||||
Assert.AreEqual(string.Empty, MonitorIdentity.FromInstanceName(@"DISPLAY\BOE0900"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
|
||||
@@ -96,6 +96,27 @@ public class MonitorSettingsRebuilderTests
|
||||
Assert.AreEqual(Now, result[0].LastSeenUtc);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Rebuild_TreatsIdsDifferingOnlyByCase_AsSameMonitor()
|
||||
{
|
||||
// Same physical monitor: discovered now spelled upper-case, previously saved lower-case.
|
||||
// The DevicePath-based Id must be matched case-insensitively so the saved entry is deduped
|
||||
// against the freshly-discovered one rather than lingering as a stale duplicate.
|
||||
var current = new List<MonitorInfo>
|
||||
{
|
||||
new() { Id = @"\\?\DISPLAY#BOE0900#4&ABC&0&UID111", EnableInputSource = true },
|
||||
};
|
||||
var existing = new List<MonitorInfo>
|
||||
{
|
||||
Existing(@"\\?\display#boe0900#4&abc&0&uid111", enableInputSource: true, Now.AddDays(-5)),
|
||||
};
|
||||
|
||||
var result = MonitorSettingsRebuilder.Rebuild(current, existing, new FixedClock(Now), retentionDays: 30);
|
||||
|
||||
Assert.AreEqual(1, result.Count, "Ids differing only by case denote the same monitor and must dedupe to one entry");
|
||||
Assert.AreEqual(@"\\?\DISPLAY#BOE0900#4&ABC&0&UID111", result[0].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Rebuild_DiscoveryRevocationRoundtrip_DoesNotLoseFlags()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ using PowerDisplay.Common.Interfaces;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Common.Utils;
|
||||
using PowerDisplay.Models;
|
||||
using static PowerDisplay.Common.Drivers.NativeConstants;
|
||||
using static PowerDisplay.Common.Drivers.NativeDelegates;
|
||||
using static PowerDisplay.Common.Drivers.PInvoke;
|
||||
@@ -149,9 +150,9 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
/// Discovers external DDC/CI-managed monitors. Each enumerated hMonitor runs its own
|
||||
/// async pipeline (filter → physical-handle retrieval → caps fetch + VCP init); all
|
||||
/// pipelines run concurrently via Task.WhenAll. Caller (MonitorManager) supplies the
|
||||
/// pre-filtered external-target list from Phase 0.
|
||||
/// displays it did not route to WMI — i.e. everything WmiMonitorBrightness did not expose.
|
||||
/// </summary>
|
||||
/// <param name="targets">External-only display targets (pre-filtered by MonitorManager Phase 0).</param>
|
||||
/// <param name="targets">Displays MonitorManager did not claim via WMI (not exposed by WmiMonitorBrightness).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of DDC/CI-managed external monitors.</returns>
|
||||
public async Task<IEnumerable<Monitor>> DiscoverMonitorsAsync(
|
||||
@@ -171,19 +172,12 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
return Enumerable.Empty<Monitor>();
|
||||
}
|
||||
|
||||
// Wrap the parallel discovery in a CrashDetectionScope. The scope writes
|
||||
// discovery.lock on Begin and deletes it on Dispose. If the process is killed
|
||||
// during capabilities I/O (BSOD, FailFast, TerminateProcess), Dispose never runs
|
||||
// and the lock survives — next PowerDisplay.exe startup notices it via CrashRecovery.
|
||||
//
|
||||
// Scope scope note: the original three-phase design wrapped only Phase 2 (cap-string
|
||||
// fetch). Main's per-handle pipeline interleaves Phase 1 (GDI/MultiMon enumeration)
|
||||
// and Phase 3 (VCP init) with the fetch, so we wrap the whole Task.WhenAll. Phase 1
|
||||
// GDI calls return null on failure (don't throw) and Phase 3 has its own catch-all
|
||||
// in BuildMonitorFromPhysical, so false-positive quarantine from those paths is not
|
||||
// observed in practice. Single Begin/Dispose per discovery is also required because
|
||||
// CrashDetectionScope uses FileMode.CreateNew + FileShare.None and cannot be nested
|
||||
// across the concurrent per-handle pipelines.
|
||||
// Wrap the whole parallel discovery in a CrashDetectionScope: it writes discovery.lock
|
||||
// on Begin and deletes it on Dispose, so if the process is killed during capabilities
|
||||
// I/O (BSOD, FailFast, TerminateProcess) the surviving lock is picked up by
|
||||
// CrashRecovery on the next startup. A single Begin/Dispose wraps the entire
|
||||
// Task.WhenAll because CrashDetectionScope uses FileMode.CreateNew + FileShare.None
|
||||
// and cannot be nested across the per-handle pipelines.
|
||||
IReadOnlyList<Monitor>[] results;
|
||||
CrashDetectionScope? scope;
|
||||
try
|
||||
@@ -212,7 +206,7 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
}
|
||||
|
||||
var monitors = results.SelectMany(r => r).ToList();
|
||||
var newHandleMap = new Dictionary<string, IntPtr>();
|
||||
var newHandleMap = new Dictionary<string, IntPtr>(MonitorIdComparer.Instance);
|
||||
foreach (var m in monitors)
|
||||
{
|
||||
newHandleMap[m.Id] = m.Handle;
|
||||
@@ -662,8 +656,8 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
|
||||
if (!targetsByGdi.TryGetValue(gdiName, out var matchingInfos))
|
||||
{
|
||||
// GDI name not in the external targets list — either a Phase 0 internal
|
||||
// panel or a target QueryDisplayConfig didn't enumerate. Skip BEFORE the
|
||||
// GDI name not in the DDC target list — either a panel already claimed by
|
||||
// WMI or a target QueryDisplayConfig didn't enumerate. Skip BEFORE the
|
||||
// expensive GetPhysicalMonitorsFromHMONITOR call.
|
||||
Logger.LogDebug($"DDC skipping {gdiName}: not in external targets list");
|
||||
return Array.Empty<Monitor>();
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ManagedCommon;
|
||||
using PowerDisplay.Models;
|
||||
using static PowerDisplay.Common.Drivers.PInvoke;
|
||||
|
||||
namespace PowerDisplay.Common.Drivers.DDC
|
||||
@@ -17,7 +18,7 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
public partial class PhysicalMonitorHandleManager : IDisposable
|
||||
{
|
||||
// Mapping: monitorId -> physical handle (thread-safe)
|
||||
private readonly ConcurrentDictionary<string, IntPtr> _monitorIdToHandleMap = new();
|
||||
private readonly ConcurrentDictionary<string, IntPtr> _monitorIdToHandleMap = new(MonitorIdComparer.Instance);
|
||||
private readonly object _handleLock = new();
|
||||
private bool _disposed;
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace PowerDisplay.Common.Drivers
|
||||
{
|
||||
/// <summary>
|
||||
/// Classifies displays as internal (built-in) or external based on the
|
||||
/// DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY enum returned by QueryDisplayConfig.
|
||||
/// Pure function helper, no side effects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference for the full set of OutputTechnology values:
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ne-wingdi-displayconfig_video_output_technology
|
||||
///
|
||||
/// Common values seen in the wild:
|
||||
/// 0 HD15 (VGA) 5 HDMI 10 DISPLAYPORT_EXTERNAL
|
||||
/// 1 SVIDEO 6 LVDS 11 DISPLAYPORT_EMBEDDED (internal)
|
||||
/// 2 COMPOSITE_VIDEO 8 D_JPN 12 UDI_EXTERNAL
|
||||
/// 3 COMPONENT_VIDEO 9 SDI 13 UDI_EMBEDDED (internal)
|
||||
/// 4 DVI 15 MIRACAST
|
||||
/// 17 INDIRECT_VIRTUAL
|
||||
/// 0x80000000 INTERNAL high-bit flag, may be combined with a subtype
|
||||
/// 0xFFFFFFFF OTHER (signed -1)
|
||||
/// </remarks>
|
||||
public static class DisplayClassifier
|
||||
{
|
||||
// High-bit flag indicating an internal display (DISPLAYCONFIG_OUTPUT_TECHNOLOGY_INTERNAL).
|
||||
private const uint InternalFlag = 0x80000000u;
|
||||
|
||||
// Documented "embedded" subtypes that mean internal connection.
|
||||
private const uint DisplayPortEmbedded = 11u;
|
||||
private const uint UdiEmbedded = 13u;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given OutputTechnology value indicates an internal display.
|
||||
/// Conservative rule: a value is internal only when it is either
|
||||
/// (a) the bare INTERNAL high-bit flag (0x80000000) with no subtype,
|
||||
/// (b) the INTERNAL flag combined with a documented embedded subtype
|
||||
/// (DISPLAYPORT_EMBEDDED 11 or UDI_EMBEDDED 13), or
|
||||
/// (c) one of those embedded subtypes on its own.
|
||||
/// Any other value — including the INTERNAL flag combined with an
|
||||
/// undocumented subtype — is treated as external. Misclassifying an
|
||||
/// external display as internal would silently drop it from DDC/CI
|
||||
/// discovery (WMI has no fallback), so we err on the side of external.
|
||||
/// LVDS (6) is intentionally NOT classified as internal — the official docs
|
||||
/// describe it only as a connector type, not as an internal-display marker.
|
||||
/// </summary>
|
||||
public static bool IsInternal(uint outputTechnology)
|
||||
{
|
||||
// Pure INTERNAL flag with no underlying value.
|
||||
if (outputTechnology == InternalFlag)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// INTERNAL combined with a known embedded subtype.
|
||||
if ((outputTechnology & InternalFlag) != 0)
|
||||
{
|
||||
var underlying = outputTechnology & ~InternalFlag;
|
||||
if (underlying == DisplayPortEmbedded || underlying == UdiEmbedded)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// INTERNAL combined with unknown/undocumented subtype: treat as external.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known embedded subtypes without the INTERNAL flag.
|
||||
return outputTechnology == DisplayPortEmbedded
|
||||
|| outputTechnology == UdiEmbedded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,8 @@ namespace PowerDisplay.Common.Drivers
|
||||
/// <summary>
|
||||
/// Win32 DisplayConfig API wrapper that enumerates all active display paths
|
||||
/// (QueryDisplayConfig + DisplayConfigGetDeviceInfo) and produces a neutral
|
||||
/// <see cref="MonitorDisplayInfo"/> inventory used by Phase 0 classification.
|
||||
/// This layer is independent of DDC/CI and WMI — both downstream controllers
|
||||
/// consume its output via <see cref="DisplayClassifier"/>.
|
||||
/// <see cref="MonitorDisplayInfo"/> inventory. This layer is independent of DDC/CI and
|
||||
/// WMI; <see cref="MonitorManager"/> routes the inventory to both downstream controllers.
|
||||
/// </summary>
|
||||
public static class DisplayConfigInventory
|
||||
{
|
||||
@@ -68,10 +67,10 @@ namespace PowerDisplay.Common.Drivers
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get target info (friendly name, device path, output technology)
|
||||
var (friendlyName, devicePath, outputTechnology) = GetTargetDeviceInfo(path.TargetInfo.AdapterId, path.TargetInfo.Id);
|
||||
// Get target info (friendly name, device path)
|
||||
var (friendlyName, devicePath) = GetTargetDeviceInfo(path.TargetInfo.AdapterId, path.TargetInfo.Id);
|
||||
|
||||
// Use device path as key - unique per target, supports mirror mode
|
||||
// Device path is the dictionary key; skip targets that don't have one.
|
||||
if (string.IsNullOrEmpty(devicePath))
|
||||
{
|
||||
continue;
|
||||
@@ -82,11 +81,7 @@ namespace PowerDisplay.Common.Drivers
|
||||
DevicePath = devicePath,
|
||||
GdiDeviceName = gdiDeviceName,
|
||||
FriendlyName = friendlyName ?? string.Empty,
|
||||
AdapterId = path.TargetInfo.AdapterId,
|
||||
TargetId = path.TargetInfo.Id,
|
||||
MonitorNumber = i + 1, // 1-based, matches Windows Display Settings
|
||||
OutputTechnology = outputTechnology,
|
||||
IsInternal = DisplayClassifier.IsInternal(outputTechnology),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -135,9 +130,9 @@ namespace PowerDisplay.Common.Drivers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets friendly name, device path, and output technology for a monitor target.
|
||||
/// Gets friendly name and device path for a monitor target.
|
||||
/// </summary>
|
||||
private static unsafe (string? FriendlyName, string? DevicePath, uint OutputTechnology) GetTargetDeviceInfo(LUID adapterId, uint targetId)
|
||||
private static unsafe (string? FriendlyName, string? DevicePath) GetTargetDeviceInfo(LUID adapterId, uint targetId)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -157,8 +152,7 @@ namespace PowerDisplay.Common.Drivers
|
||||
{
|
||||
return (
|
||||
deviceName.GetMonitorFriendlyDeviceName(),
|
||||
deviceName.GetMonitorDevicePath(),
|
||||
deviceName.OutputTechnology);
|
||||
deviceName.GetMonitorDevicePath());
|
||||
}
|
||||
|
||||
Logger.LogWarning(
|
||||
@@ -170,7 +164,7 @@ namespace PowerDisplay.Common.Drivers
|
||||
$"DisplayConfigInventory: GetTargetDeviceInfo exception (adapter.low=0x{adapterId.LowPart:X}, target={targetId}): {ex.Message}");
|
||||
}
|
||||
|
||||
return (null, null, 0u);
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Windows.Win32.Foundation;
|
||||
|
||||
namespace PowerDisplay.Common.Drivers
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor display information structure produced by QueryDisplayConfig.
|
||||
/// Used by MonitorManager Phase 0 classification and by both controllers
|
||||
/// during discovery. Immutable value type — populated once by
|
||||
/// <see cref="DisplayConfigInventory"/> and read-only thereafter.
|
||||
/// Used by MonitorManager during discovery and by both controllers. Immutable value
|
||||
/// type — populated once by <see cref="DisplayConfigInventory"/> and read-only thereafter.
|
||||
/// </summary>
|
||||
public readonly record struct MonitorDisplayInfo
|
||||
{
|
||||
@@ -32,27 +29,11 @@ namespace PowerDisplay.Common.Drivers
|
||||
/// </summary>
|
||||
public string FriendlyName { get; init; }
|
||||
|
||||
public LUID AdapterId { get; init; }
|
||||
|
||||
public uint TargetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the monitor number based on QueryDisplayConfig path index.
|
||||
/// This matches the number shown in Windows Display Settings "Identify" feature.
|
||||
/// 1-based index (paths[0] = 1, paths[1] = 2, etc.)
|
||||
/// </summary>
|
||||
public int MonitorNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY value reported
|
||||
/// by QueryDisplayConfig. Preserved for diagnostic logging.
|
||||
/// </summary>
|
||||
public uint OutputTechnology { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this display is classified as internal (built-in).
|
||||
/// Computed from OutputTechnology by DisplayClassifier.IsInternal during Phase 0.
|
||||
/// </summary>
|
||||
public bool IsInternal { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using ManagedCommon;
|
||||
using PowerDisplay.Common.Drivers;
|
||||
using PowerDisplay.Common.Interfaces;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Models;
|
||||
using WmiLight;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
@@ -192,25 +193,22 @@ namespace PowerDisplay.Common.Drivers.WMI
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover supported monitors.
|
||||
/// WMI brightness control is typically only available on internal laptop displays.
|
||||
/// The monitor Name is left blank here; the ViewModel layer fills in a localized
|
||||
/// "Built-in Display" string so it can be translated for the user's UI language.
|
||||
/// Discover the panels the WMI brightness provider exposes, pairing each against the
|
||||
/// active-display inventory. A display present in <c>WmiMonitorBrightness</c> is treated
|
||||
/// as an internal panel by <see cref="MonitorManager"/>, regardless of the OutputTechnology
|
||||
/// the active GPU reports — this is what lets a built-in panel driven by the discrete GPU
|
||||
/// (reported as DisplayPort-External) still be found. See issue #48587.
|
||||
/// </summary>
|
||||
/// <param name="targets">Internal-only display targets (pre-filtered by MonitorManager Phase 0).</param>
|
||||
/// <param name="targets">The full active-display inventory from QueryDisplayConfig.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of WMI-managed internal monitors.</returns>
|
||||
/// <returns>WMI-managed monitors (those present in both WMI and the inventory).</returns>
|
||||
public async Task<IEnumerable<Monitor>> DiscoverMonitorsAsync(
|
||||
IReadOnlyList<MonitorDisplayInfo> targets,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Short-circuit: with no internal displays classified there is nothing for WMI
|
||||
// brightness control to do. Skipping the query also avoids the WmiMonitorBrightness
|
||||
// class throwing WMI 0x1068 ("feature not supported") on systems without an
|
||||
// internal panel — that exception is otherwise caught and logged as Error.
|
||||
// No active displays at all — nothing to pair WMI brightness instances against.
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
Logger.LogInfo("WMI: No internal displays classified — skipping WmiMonitorBrightness query");
|
||||
return Enumerable.Empty<Monitor>();
|
||||
}
|
||||
|
||||
@@ -219,32 +217,25 @@ namespace PowerDisplay.Common.Drivers.WMI
|
||||
{
|
||||
var monitors = new List<Monitor>();
|
||||
|
||||
// Build PnP-hardware-key -> MonitorDisplayInfo lookup. The PnP key (manufacturer
|
||||
// code + PnP instance UID) is globally unique per physical device and present in
|
||||
// both WMI InstanceName and DevicePath, so this is a one-step exact match that
|
||||
// handles dual-internal-panel devices (e.g. Yoga Book 9i, Zenbook Duo) without
|
||||
// needing any disambiguation pass.
|
||||
var monitorDisplayInfos = targets
|
||||
.Select(t => (Key: MonitorIdentity.PnpHardwareKeyFromDevicePath(t.DevicePath), Info: t))
|
||||
.Where(p => !string.IsNullOrEmpty(p.Key))
|
||||
.ToDictionary(p => p.Key, p => p.Info, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Track which internal targets (keyed by DevicePath, the unique target id) were
|
||||
// observed via WmiMonitorBrightness so we can warn about any that were classified
|
||||
// internal but not exposed.
|
||||
var seenDevicePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
// Key the inventory by canonical Monitor.Id (FromDevicePath). A WMI InstanceName
|
||||
// reduces to the same Id via FromInstanceName, so pairing is a single exact lookup
|
||||
// that also disambiguates dual-internal-panel devices without a separate pass.
|
||||
var byId = targets
|
||||
.Select(t => (Id: MonitorIdentity.FromDevicePath(t.DevicePath), Info: t))
|
||||
.Where(p => !string.IsNullOrEmpty(p.Id))
|
||||
.ToDictionary(p => p.Id, p => p.Info, MonitorIdComparer.Instance);
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = new WmiConnection(WmiNamespace);
|
||||
|
||||
// Query WMI brightness support - only internal displays typically support this
|
||||
// System-wide query: returns every panel the driver exposes for WMI
|
||||
// brightness, regardless of which GPU currently drives it.
|
||||
var brightnessQuery = $"SELECT InstanceName, CurrentBrightness FROM {BrightnessQueryClass}";
|
||||
var brightnessResults = connection.CreateQuery(brightnessQuery).ToList();
|
||||
|
||||
// Create monitor objects for each supported brightness instance.
|
||||
// Check cancellation per iteration since WMI work inside Task.Run
|
||||
// doesn't respond to the token after the loop starts.
|
||||
// Check cancellation per iteration: WMI work inside Task.Run doesn't
|
||||
// respond to the token once the loop has started.
|
||||
foreach (var obj in brightnessResults)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -254,39 +245,38 @@ namespace PowerDisplay.Common.Drivers.WMI
|
||||
var instanceName = obj.GetPropertyValue<string>("InstanceName") ?? string.Empty;
|
||||
var currentBrightness = obj.GetPropertyValue<byte>("CurrentBrightness");
|
||||
|
||||
// Derive the same PnP hardware key from the WMI InstanceName and look up
|
||||
// the matching MonitorDisplayInfo — exact, unique, no disambiguation needed.
|
||||
var pnpKey = MonitorIdentity.PnpHardwareKeyFromInstanceName(instanceName);
|
||||
if (string.IsNullOrEmpty(pnpKey) || !monitorDisplayInfos.TryGetValue(pnpKey, out var displayInfo))
|
||||
// Pair on the canonical Monitor.Id. A miss means this WMI instance is
|
||||
// not an active display (e.g. a disconnected panel still cached by the
|
||||
// provider) — skip it.
|
||||
var lookupId = MonitorIdentity.FromInstanceName(instanceName);
|
||||
if (string.IsNullOrEmpty(lookupId) || !byId.TryGetValue(lookupId, out var displayInfo))
|
||||
{
|
||||
Logger.LogWarning(
|
||||
$"WMI returned brightness for instance '{instanceName}' but no matching " +
|
||||
"QueryDisplayConfig target was found — skipping");
|
||||
Logger.LogInfo(
|
||||
$"WMI exposed brightness for instance '{instanceName}' with no matching " +
|
||||
"active display — skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
// DevicePath is guaranteed non-empty here: the PnP-key lookup above
|
||||
// only succeeds for targets whose key was derived from a populated
|
||||
// DevicePath.
|
||||
seenDevicePaths.Add(displayInfo.DevicePath);
|
||||
string uniqueId = MonitorIdentity.FromDevicePath(displayInfo.DevicePath);
|
||||
|
||||
int monitorNumber = displayInfo.MonitorNumber;
|
||||
string gdiDeviceName = displayInfo.GdiDeviceName ?? string.Empty;
|
||||
|
||||
// Derive the Id from the matched entry's DevicePath, not the
|
||||
// reconstructed lookupId. The persisted Monitor.Id ALWAYS comes from this
|
||||
// single source (FromDevicePath), so a WMI panel's Id stays byte-identical
|
||||
// to the DDC route and to prior releases. FromInstanceName is only the
|
||||
// lookup key; every Id comparison/key elsewhere goes through MonitorIdComparer
|
||||
// (case-insensitive), so an InstanceName/DevicePath casing difference can
|
||||
// never orphan per-monitor settings.
|
||||
// Name is left blank: MonitorViewModel injects a localized
|
||||
// "Built-in Display" string for internal displays.
|
||||
var monitor = new Monitor
|
||||
{
|
||||
Id = uniqueId,
|
||||
Id = MonitorIdentity.FromDevicePath(displayInfo.DevicePath),
|
||||
Name = string.Empty,
|
||||
CurrentBrightness = currentBrightness,
|
||||
InstanceName = instanceName,
|
||||
Capabilities = MonitorCapabilities.Brightness | MonitorCapabilities.Wmi,
|
||||
CommunicationMethod = "WMI",
|
||||
SupportsColorTemperature = false,
|
||||
MonitorNumber = monitorNumber,
|
||||
GdiDeviceName = gdiDeviceName,
|
||||
MonitorNumber = displayInfo.MonitorNumber,
|
||||
GdiDeviceName = displayInfo.GdiDeviceName ?? string.Empty,
|
||||
};
|
||||
|
||||
monitors.Add(monitor);
|
||||
@@ -299,28 +289,16 @@ namespace PowerDisplay.Common.Drivers.WMI
|
||||
}
|
||||
catch (WmiException ex)
|
||||
{
|
||||
Logger.LogError($"WMI DiscoverMonitors failed: {ex.Message} (HResult: 0x{ex.HResult:X})");
|
||||
// On a system with no WMI-controllable panel the provider may be absent or
|
||||
// throw 0x1068 ("feature not supported"); those displays are handled by
|
||||
// DDC/CI instead, so this is informational rather than an error.
|
||||
Logger.LogInfo($"WMI brightness query unavailable: {ex.Message} (HResult: 0x{ex.HResult:X})");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.LogError($"WMI DiscoverMonitors failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Warn about every internal target the driver didn't expose via WMI.
|
||||
// DevicePath is per-target unique, so dual-internal-panel devices report each
|
||||
// missing panel separately.
|
||||
foreach (var target in targets)
|
||||
{
|
||||
if (seenDevicePaths.Contains(target.DevicePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.LogWarning(
|
||||
$"Internal display \"{target.FriendlyName}\" ({target.DevicePath}) was classified internal " +
|
||||
"but is not exposed via WmiMonitorBrightness — driver may not support brightness control");
|
||||
}
|
||||
|
||||
return monitors;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
@@ -36,39 +36,18 @@ public static class MonitorIdentity
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the PnP hardware key from a DevicePath. The key identifies a physical
|
||||
/// monitor across both QueryDisplayConfig (DevicePath) and WMI (InstanceName)
|
||||
/// representations, so it is the right join key for pairing WMI brightness instances
|
||||
/// with MonitorDisplayInfo entries.
|
||||
/// Convert a WMI <c>WmiMonitorBrightness.InstanceName</c> (e.g.,
|
||||
/// "DISPLAY\BOE0900\4&...&UID111_0") into the same canonical
|
||||
/// <see cref="Monitor.Id"/> that <see cref="FromDevicePath"/> produces from the
|
||||
/// matching QueryDisplayConfig DevicePath for the same physical monitor — e.g.,
|
||||
/// "\\?\DISPLAY#BOE0900#4&...&UID111". Deriving the Id from each source and
|
||||
/// comparing is the join key for pairing WMI brightness instances with
|
||||
/// <c>MonitorDisplayInfo</c> entries; it stays unique even on dual-internal-panel
|
||||
/// devices (Yoga Book 9i, Zenbook Duo) where two panels share an EdidId but differ
|
||||
/// in PnP UID.
|
||||
/// </summary>
|
||||
/// <param name="devicePath">DevicePath of the form "\\?\DISPLAY#BOE0900#4&...&UID111#{guid}".</param>
|
||||
/// <returns>Canonical key "BOE0900#4&...&UID111", or empty string if extraction fails.</returns>
|
||||
public static string PnpHardwareKeyFromDevicePath(string? devicePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(devicePath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Split: ["\\?\DISPLAY", "BOE0900", "4&...&UID111", "{guid}"]
|
||||
var parts = devicePath.Split('#');
|
||||
if (parts.Length < 3 || string.IsNullOrEmpty(parts[1]) || string.IsNullOrEmpty(parts[2]))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{parts[1]}#{parts[2]}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the PnP hardware key from a WMI InstanceName. Produces the same canonical
|
||||
/// form as <see cref="PnpHardwareKeyFromDevicePath"/> for the same physical device,
|
||||
/// enabling reliable one-step matching even on dual-internal-panel devices where
|
||||
/// two panels share an EdidId but differ in PnP UID.
|
||||
/// </summary>
|
||||
/// <param name="instanceName">InstanceName of the form "DISPLAY\BOE0900\4&...&UID111_0".</param>
|
||||
/// <returns>Canonical key "BOE0900#4&...&UID111", or empty string if extraction fails.</returns>
|
||||
public static string PnpHardwareKeyFromInstanceName(string? instanceName)
|
||||
/// <param name="instanceName">InstanceName of the form "DISPLAY\<EdidId>\<instance>_<N>". Null, empty, or not a three-segment InstanceName returns empty string.</param>
|
||||
public static string FromInstanceName(string? instanceName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(instanceName))
|
||||
{
|
||||
@@ -82,7 +61,7 @@ public static class MonitorIdentity
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Strip the trailing "_N" WMI-instance suffix (e.g. "..._0").
|
||||
// Strip the trailing "_N" WMI-instance suffix (e.g. "..._0", "..._12").
|
||||
var instanceSegment = parts[2];
|
||||
var underscore = instanceSegment.LastIndexOf('_');
|
||||
if (underscore > 0)
|
||||
@@ -90,7 +69,11 @@ public static class MonitorIdentity
|
||||
instanceSegment = instanceSegment[..underscore];
|
||||
}
|
||||
|
||||
return $"{parts[1]}#{instanceSegment}";
|
||||
// Reshape into the canonical DevicePath-style Monitor.Id: a WMI InstanceName uses
|
||||
// "\" separators and omits the "\\?\" device-interface prefix, whereas a
|
||||
// QueryDisplayConfig DevicePath uses "#" separators with that prefix. parts[0] is
|
||||
// the enumerator ("DISPLAY"), reused rather than hardcoded.
|
||||
return $@"\\?\{parts[0]}#{parts[1]}#{instanceSegment}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -56,7 +56,7 @@ public static class MonitorSettingsRebuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.Any(m => m.Id == existingMonitor.Id))
|
||||
if (result.Any(m => MonitorIdComparer.Equal(m.Id, existingMonitor.Id)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using ManagedCommon;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Serialization;
|
||||
using PowerDisplay.Common.Utils;
|
||||
using PowerDisplay.Models;
|
||||
|
||||
namespace PowerDisplay.Common.Services
|
||||
{
|
||||
@@ -25,7 +26,7 @@ namespace PowerDisplay.Common.Services
|
||||
public partial class MonitorStateManager : IDisposable
|
||||
{
|
||||
private readonly string _stateFilePath;
|
||||
private readonly ConcurrentDictionary<string, MonitorState> _states = new();
|
||||
private readonly ConcurrentDictionary<string, MonitorState> _states = new(MonitorIdComparer.Instance);
|
||||
private readonly SimpleDebouncer _saveDebouncer;
|
||||
|
||||
private volatile bool _disposed;
|
||||
|
||||
@@ -445,7 +445,7 @@ namespace PowerDisplay.Common.Utils
|
||||
var custom = customMappings.FirstOrDefault(m =>
|
||||
m.VcpCode == vcpCode &&
|
||||
m.Value == value &&
|
||||
(m.ApplyToAll || (!m.ApplyToAll && m.TargetMonitorId == monitorId)));
|
||||
(m.ApplyToAll || (!m.ApplyToAll && MonitorIdComparer.Equal(m.TargetMonitorId, monitorId))));
|
||||
|
||||
if (custom != null && !string.IsNullOrEmpty(custom.CustomName))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
|
||||
namespace PowerDisplay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The single canonical equality policy for a monitor's stable Id (the DevicePath-based
|
||||
/// <c>"\\?\DISPLAY#<EdidId>#<instance>"</c> string). Every dictionary, hash set,
|
||||
/// and equality check keyed on a monitor Id MUST go through this type so the policy lives in
|
||||
/// exactly one place.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Ordinal and case-<b>insensitive</b>. A persisted Id is normally re-derived from the
|
||||
/// QueryDisplayConfig DevicePath (<c>MonitorIdentity.FromDevicePath</c>), so the same physical
|
||||
/// monitor reproduces a byte-identical Id across runs and case-sensitive matching happens to
|
||||
/// work today. But the WMI brightness <c>InstanceName</c> and the DevicePath for the same panel
|
||||
/// can differ in casing (already handled case-insensitively where they are joined), and the
|
||||
/// DevicePath casing is not guaranteed stable across driver updates or GPU-route changes. To
|
||||
/// avoid orphaning per-monitor settings on a mere casing change — and to keep one consistent
|
||||
/// rule across the in-memory join and the persisted stores — Id casing is treated as
|
||||
/// non-significant everywhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lives in <c>PowerDisplay.Models</c> because it is the only project referenced by both the
|
||||
/// discovery/persistence code (<c>PowerDisplay.Lib</c>) and the settings library
|
||||
/// (<c>Settings.UI.Library</c> / <c>Settings.UI</c>) that key collections on a monitor Id.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MonitorIdComparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical comparer for monitor-Id-keyed <see cref="System.Collections.Generic.Dictionary{TKey,TValue}"/>,
|
||||
/// <see cref="System.Collections.Generic.HashSet{T}"/>, and LINQ lookups.
|
||||
/// </summary>
|
||||
public static readonly StringComparer Instance = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when two monitor Ids denote the same monitor under the
|
||||
/// canonical policy. Use in place of the <c>==</c> operator when comparing monitor Ids.
|
||||
/// </summary>
|
||||
public static bool Equal(string? left, string? right)
|
||||
=> string.Equals(left, right, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -16,6 +15,7 @@ using PowerDisplay.Common.Interfaces;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Common.Utils;
|
||||
using PowerDisplay.Models;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.Helpers
|
||||
@@ -27,7 +27,7 @@ namespace PowerDisplay.Helpers
|
||||
public partial class MonitorManager : IDisposable
|
||||
{
|
||||
private readonly List<Monitor> _monitors = new();
|
||||
private readonly Dictionary<string, Monitor> _monitorLookup = new();
|
||||
private readonly Dictionary<string, Monitor> _monitorLookup = new(MonitorIdComparer.Instance);
|
||||
private readonly SemaphoreSlim _discoveryLock = new(1, 1);
|
||||
private readonly DisplayRotationService _rotationService = new();
|
||||
|
||||
@@ -123,18 +123,19 @@ namespace PowerDisplay.Helpers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify all displays via OutputTechnology using a single QueryDisplayConfig
|
||||
/// call, then dispatch strictly-scoped target lists to each controller in parallel
|
||||
/// (WMI = internal only, DDC/CI = external only).
|
||||
/// Discover monitors by capability, not by nominal output technology. WMI runs first
|
||||
/// over the full QueryDisplayConfig inventory; every display it claims is a
|
||||
/// WMI-controllable internal panel. Whatever WMI does not claim is then sent to DDC/CI.
|
||||
/// This avoids incorrectly routing a built-in panel that the active (discrete) GPU reports as
|
||||
/// DisplayPort-External — the root cause of issue #48587.
|
||||
/// </summary>
|
||||
private async Task<List<Monitor>> DiscoverFromAllControllersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var inventory = DisplayConfigInventory.GetAllMonitorDisplayInfo();
|
||||
|
||||
// Filter blacklisted monitors out of the inventory before any controller
|
||||
// is dispatched. Matching uses MonitorIdentity.EdidIdFromMonitorId on each
|
||||
// entry's DevicePath, so blocked monitors are not opened, probed, or queried
|
||||
// — the whole point of the blacklist over the per-monitor IsHidden flag.
|
||||
// Filter blacklisted monitors before any controller runs, so blocked displays are
|
||||
// never opened, probed, or queried (unlike the per-monitor IsHidden flag). Matching
|
||||
// is by MonitorIdentity.EdidIdFromMonitorId on each entry's DevicePath.
|
||||
var beforeCount = inventory.Count;
|
||||
var filteredInventory = new Dictionary<string, MonitorDisplayInfo>(
|
||||
inventory.Count, StringComparer.OrdinalIgnoreCase);
|
||||
@@ -165,59 +166,61 @@ namespace PowerDisplay.Helpers
|
||||
return new List<Monitor>();
|
||||
}
|
||||
|
||||
var byKind = inventory.Values.ToLookup(i => i.IsInternal);
|
||||
IReadOnlyList<MonitorDisplayInfo> internalTargets = byKind[true].ToList();
|
||||
IReadOnlyList<MonitorDisplayInfo> externalTargets = byKind[false].ToList();
|
||||
var allDisplays = inventory.Values.ToList();
|
||||
|
||||
LogClassificationSummary(internalTargets, externalTargets);
|
||||
// Phase 1: WMI over the full inventory — whatever it claims is an internal panel.
|
||||
var wmiMonitors = _wmiController != null
|
||||
? (await SafeDiscoverAsync(_wmiController, allDisplays, cancellationToken)).ToList()
|
||||
: new List<Monitor>();
|
||||
|
||||
var tasks = new List<Task<IEnumerable<Monitor>>>();
|
||||
var wmiClaimedIds = new HashSet<string>(
|
||||
wmiMonitors.Select(m => m.Id), MonitorIdComparer.Instance);
|
||||
|
||||
if (_ddcController != null)
|
||||
{
|
||||
tasks.Add(SafeDiscoverAsync(_ddcController, externalTargets, cancellationToken));
|
||||
}
|
||||
// Phase 2: everything WMI did not claim goes to DDC/CI. Accepted trade-off — a
|
||||
// monitor exposing both is controlled via WMI only and won't get DDC-only features
|
||||
// (contrast/volume/input). Partition once so FromDevicePath runs a single time each.
|
||||
var byRoute = allDisplays.ToLookup(
|
||||
d => wmiClaimedIds.Contains(MonitorIdentity.FromDevicePath(d.DevicePath)));
|
||||
IReadOnlyList<MonitorDisplayInfo> wmiTargets = byRoute[true].ToList();
|
||||
IReadOnlyList<MonitorDisplayInfo> ddcTargets = byRoute[false].ToList();
|
||||
|
||||
if (_wmiController != null)
|
||||
{
|
||||
tasks.Add(SafeDiscoverAsync(_wmiController, internalTargets, cancellationToken));
|
||||
}
|
||||
LogClassificationSummary(wmiTargets, ddcTargets);
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results.SelectMany(m => m).ToList();
|
||||
var ddcMonitors = _ddcController != null
|
||||
? (await SafeDiscoverAsync(_ddcController, ddcTargets, cancellationToken)).ToList()
|
||||
: new List<Monitor>();
|
||||
|
||||
return wmiMonitors.Concat(ddcMonitors).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the result of Phase 0 classification at Info level, one line per display
|
||||
/// plus a summary. Used for diagnostic traceability of internal/external decisions.
|
||||
/// Logs how each display was routed (WMI vs DDC/CI) at Info level, one line per
|
||||
/// display plus a summary. Runs after WMI discovery but before the crash-prone DDC/CI
|
||||
/// capability fetch, so every attached model's EdidId is on disk for crash correlation.
|
||||
/// </summary>
|
||||
private static void LogClassificationSummary(
|
||||
IReadOnlyList<MonitorDisplayInfo> internalTargets,
|
||||
IReadOnlyList<MonitorDisplayInfo> externalTargets)
|
||||
IReadOnlyList<MonitorDisplayInfo> wmiTargets,
|
||||
IReadOnlyList<MonitorDisplayInfo> ddcTargets)
|
||||
{
|
||||
Logger.LogInfo($"[DisplayClassification] Found {internalTargets.Count + externalTargets.Count} displays:");
|
||||
Logger.LogInfo($"[DisplayClassification] Found {wmiTargets.Count + ddcTargets.Count} displays:");
|
||||
|
||||
foreach (var info in internalTargets.Concat(externalTargets).OrderBy(i => i.MonitorNumber))
|
||||
var wmiPaths = new HashSet<string>(wmiTargets.Select(t => t.DevicePath), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var info in wmiTargets.Concat(ddcTargets).OrderBy(i => i.MonitorNumber))
|
||||
{
|
||||
var techValue = info.OutputTechnology >= 0x80000000u
|
||||
? "0x" + info.OutputTechnology.ToString("X", CultureInfo.InvariantCulture)
|
||||
: info.OutputTechnology.ToString(CultureInfo.InvariantCulture);
|
||||
var classification = info.IsInternal ? "Internal" : "External";
|
||||
var route = wmiPaths.Contains(info.DevicePath) ? "WMI (internal)" : "DDC/CI (external)";
|
||||
|
||||
// Log EdidId (manufacturer+product code from EDID) up front, before any
|
||||
// DDC/CI capability fetch runs. QueryDisplayConfig reads OS-cached EDID and
|
||||
// cannot BSOD, so this line is guaranteed on disk before the crash-prone
|
||||
// Phase 2 fetch starts — recovered logs identify every attached model
|
||||
// (and same-model duplicates) for crash correlation.
|
||||
// EdidId (manufacturer+product code) is logged here, before the BSOD-prone DDC
|
||||
// capability fetch, so recovered logs identify every attached model (and
|
||||
// same-model duplicates) for crash correlation.
|
||||
var edidId = MonitorIdentity.EdidIdFromMonitorId(info.DevicePath);
|
||||
var edidIdField = string.IsNullOrEmpty(edidId) ? "?" : edidId;
|
||||
|
||||
Logger.LogInfo(
|
||||
$" [Path {info.MonitorNumber}] EdidId={edidIdField} {info.GdiDeviceName} / \"{info.FriendlyName}\": " +
|
||||
$"OutputTechnology={techValue} → {classification}");
|
||||
$" [Path {info.MonitorNumber}] EdidId={edidIdField} {info.GdiDeviceName} / \"{info.FriendlyName}\" → {route}");
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[DisplayClassification] Summary: {internalTargets.Count} internal, {externalTargets.Count} external");
|
||||
Logger.LogInfo($"[DisplayClassification] Summary: {wmiTargets.Count} WMI, {ddcTargets.Count} DDC/CI");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -11,6 +11,7 @@ using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Helpers;
|
||||
using PowerDisplay.Models;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.ViewModels;
|
||||
@@ -183,5 +184,6 @@ public partial class MainViewModel
|
||||
=> new HashSet<string>(
|
||||
settings.Properties.Monitors
|
||||
.Where(m => m.IsHidden)
|
||||
.Select(m => m.Id));
|
||||
.Select(m => m.Id),
|
||||
MonitorIdComparer.Instance);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ public partial class MainViewModel
|
||||
foreach (var setting in monitorSettings)
|
||||
{
|
||||
// Find monitor by Id (unique identifier)
|
||||
var monitorVm = Monitors.FirstOrDefault(m => m.Id == setting.MonitorId);
|
||||
var monitorVm = Monitors.FirstOrDefault(m => MonitorIdComparer.Equal(m.Id, setting.MonitorId));
|
||||
|
||||
if (monitorVm == null)
|
||||
{
|
||||
@@ -293,7 +293,7 @@ public partial class MainViewModel
|
||||
private void ApplyFeatureVisibility(MonitorViewModel monitorVm, PowerDisplaySettings settings)
|
||||
{
|
||||
var monitorSettings = settings.Properties.Monitors.FirstOrDefault(m =>
|
||||
m.Id == monitorVm.Id);
|
||||
MonitorIdComparer.Equal(m.Id, monitorVm.Id));
|
||||
|
||||
if (monitorSettings != null)
|
||||
{
|
||||
@@ -340,8 +340,8 @@ public partial class MainViewModel
|
||||
// Filter out monitors with empty IDs to avoid dictionary key collision errors
|
||||
var existingMonitorSettings = settings.Properties.Monitors
|
||||
.Where(m => !string.IsNullOrEmpty(m.Id))
|
||||
.GroupBy(m => m.Id)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
.GroupBy(m => m.Id, MonitorIdComparer.Instance)
|
||||
.ToDictionary(g => g.Key, g => g.First(), MonitorIdComparer.Instance);
|
||||
|
||||
// Build monitor list using Settings UI's MonitorInfo model
|
||||
// Only include monitors with valid (non-empty) IDs to auto-fix corrupted settings
|
||||
@@ -394,7 +394,7 @@ public partial class MainViewModel
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = monitors.FirstOrDefault(m => m.Id == newId);
|
||||
var target = monitors.FirstOrDefault(m => MonitorIdComparer.Equal(m.Id, newId));
|
||||
if (target != null)
|
||||
{
|
||||
CopyUserFlags(target, legacy);
|
||||
@@ -566,7 +566,7 @@ public partial class MainViewModel
|
||||
.ToList())
|
||||
{
|
||||
var newId = MonitorIdMigrator.MatchNewId(legacy.MonitorId, discovered);
|
||||
if (newId != null && profile.MonitorSettings.All(s => s.MonitorId != newId))
|
||||
if (newId != null && profile.MonitorSettings.All(s => !MonitorIdComparer.Equal(s.MonitorId, newId)))
|
||||
{
|
||||
profile.MonitorSettings.Add(new ProfileMonitorSetting(
|
||||
newId,
|
||||
|
||||
@@ -681,8 +681,8 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set power state for this monitor.
|
||||
/// Note: Setting any state other than "On" will turn off the display.
|
||||
/// Set the monitor's power state via VCP 0xD6: On (0x01) wakes the display,
|
||||
/// Standby/Suspend/Off put it to sleep.
|
||||
/// </summary>
|
||||
public async Task SetPowerStateAsync(int powerState)
|
||||
{
|
||||
@@ -712,18 +712,6 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command to set power state
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task SetPowerState(int? state)
|
||||
{
|
||||
if (state.HasValue)
|
||||
{
|
||||
await SetPowerStateAsync(state.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public int Contrast
|
||||
{
|
||||
get => _contrast;
|
||||
@@ -880,11 +868,9 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Value == PowerStateItem.PowerStateOn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the selected state straight to the hardware. Selecting On (0x01) wakes a
|
||||
// sleeping monitor: DDC/CI stays reachable in Standby/Suspend/Off(DPM), so the
|
||||
// write turns the panel back on (Off(Hard)/0x05 may still need a physical wake).
|
||||
await SetPowerStateAsync(item.Value);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,6 @@ namespace PowerDisplay.ViewModels;
|
||||
/// </summary>
|
||||
public class PowerStateItem
|
||||
{
|
||||
/// <summary>
|
||||
/// VCP power mode value representing On state
|
||||
/// </summary>
|
||||
public const int PowerStateOn = 0x01;
|
||||
|
||||
/// <summary>
|
||||
/// VCP value for this power state
|
||||
/// </summary>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace Microsoft.PowerToys.QuickAccess;
|
||||
@@ -14,14 +15,26 @@ public partial class App : Application
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
UnhandledException += App_UnhandledException;
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
var launchContext = QuickAccessLaunchContext.Parse(Environment.GetCommandLineArgs());
|
||||
_window = new MainWindow(launchContext);
|
||||
_window.Closed += OnWindowClosed;
|
||||
_window.Activate();
|
||||
try
|
||||
{
|
||||
var launchContext = QuickAccessLaunchContext.Parse(Environment.GetCommandLineArgs());
|
||||
_window = new MainWindow(launchContext);
|
||||
_window.Closed += OnWindowClosed;
|
||||
_window.Activate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Failing here means the flyout host could not be constructed. Log and exit cleanly
|
||||
// rather than letting the throw bubble out into a stowed XAML failure that crashes
|
||||
// the runner-owned launcher.
|
||||
Logger.LogError("QuickAccess: failed to launch flyout host.", ex);
|
||||
Exit();
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnWindowClosed(object sender, WindowEventArgs args)
|
||||
@@ -33,4 +46,13 @@ public partial class App : Application
|
||||
|
||||
_window = null;
|
||||
}
|
||||
|
||||
private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
// QuickAccess is a transient launcher flyout owned by the runner. An unhandled XAML
|
||||
// exception here would otherwise be stowed and FailFast the process; mark the event
|
||||
// handled so the next summon can recover. The error is still recorded for diagnostics.
|
||||
Logger.LogError("QuickAccess: unhandled XAML exception.", e.Exception);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
// 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.PowerToys.QuickAccess.Services;
|
||||
using Microsoft.PowerToys.QuickAccess.ViewModels;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media.Animation;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
|
||||
namespace Microsoft.PowerToys.QuickAccess.Flyout;
|
||||
|
||||
@@ -22,6 +24,7 @@ public sealed partial class ShellPage : Page
|
||||
public ShellPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
ContentFrame.NavigationFailed += ContentFrame_NavigationFailed;
|
||||
}
|
||||
|
||||
public void Initialize(IQuickAccessCoordinator coordinator, LauncherViewModel launcherViewModel, AllAppsViewModel allAppsViewModel)
|
||||
@@ -65,4 +68,13 @@ public sealed partial class ShellPage : Page
|
||||
appsListPage.ViewModel?.RefreshSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ContentFrame_NavigationFailed(object sender, NavigationFailedEventArgs e)
|
||||
{
|
||||
// A page constructor or XAML load failure here would otherwise bubble out of the
|
||||
// Frame and crash the launcher. Log the failure and mark it handled so the flyout
|
||||
// can remain available; the next summon will retry navigation.
|
||||
Logger.LogError($"QuickAccess: navigation to '{e.SourcePageType?.FullName}' failed.", e.Exception);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +639,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
else
|
||||
{
|
||||
// Create a dictionary for quick lookup by Id
|
||||
var updatedMonitorsDict = updatedMonitors.ToDictionary(m => m.Id, m => m);
|
||||
var updatedMonitorsDict = updatedMonitors.ToDictionary(m => m.Id, m => m, MonitorIdComparer.Instance);
|
||||
|
||||
// Update existing monitors or remove ones that no longer exist
|
||||
for (int i = Monitors.Count - 1; i >= 0; i--)
|
||||
|
||||
Reference in New Issue
Block a user