diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5e5ea03e7e..1a85de1e06 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,6 +7,13 @@ body: - type: markdown attributes: value: Please make sure to [search for existing issues](https://github.com/microsoft/PowerToys/issues) before filing a new one! +- type: markdown + attributes: + value: | + We are aware of the following high-volume issues and are actively working on them. Please check if your issue is one of these before filing a new bug report: + * **PowerToys Run crash related to "Desktop composition is disabled"**: This may appear as `COMException: 0x80263001`. For more details, see issue [#31226](https://github.com/microsoft/PowerToys/issues/31226). + * **PowerToys Run crash with `COMException (0xD0000701)`**: For more details, see issue [#30769](https://github.com/microsoft/PowerToys/issues/30769). + * **PowerToys Run crash with a "Cyclic reference" error**: This `System.InvalidOperationException` is detailed in issue [#36451](https://github.com/microsoft/PowerToys/issues/36451). - id: version type: input attributes: @@ -58,6 +65,7 @@ body: - Image Resizer - Installer - Keyboard Manager + - Light Switch - Mouse Utilities - Mouse Without Borders - New+ diff --git a/.github/ISSUE_TEMPLATE/translation_issue.yml b/.github/ISSUE_TEMPLATE/translation_issue.yml index 1fdefbff8b..63b998822f 100644 --- a/.github/ISSUE_TEMPLATE/translation_issue.yml +++ b/.github/ISSUE_TEMPLATE/translation_issue.yml @@ -38,6 +38,7 @@ body: - Image Resizer - Installer - Keyboard Manager + - Light Switch - Mouse Utilities - Mouse Without Borders - New+ diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt index 756c450534..41a53d33ed 100644 --- a/.github/actions/spell-check/allow/code.txt +++ b/.github/actions/spell-check/allow/code.txt @@ -321,3 +321,10 @@ REGSTR # Misc Win32 APIs and PInvokes INVOKEIDLIST + +# PowerRename metadata pattern abbreviations (used in tests and regex patterns) +DDDD +FFF +HHH +riday +YYY diff --git a/.github/actions/spell-check/allow/names.txt b/.github/actions/spell-check/allow/names.txt index 475e68045b..ab2446f8ae 100644 --- a/.github/actions/spell-check/allow/names.txt +++ b/.github/actions/spell-check/allow/names.txt @@ -29,8 +29,6 @@ shortcutguide # 8LWXpg is user name but user folder causes a flag LWXpg -# 0x6f677548 is user name but user folder causes a flag -x6f677548 Adoumie Advaith alekhyareddy @@ -210,6 +208,7 @@ capturevideosample cmdow Controlz cortana +devhints dlnilsson fancymouse firefox @@ -229,6 +228,7 @@ regedit roslyn Skia Spotify +tldr Vanara wangyi WEX diff --git a/.github/actions/spell-check/excludes.txt b/.github/actions/spell-check/excludes.txt index 7ad88bde19..551c248923 100644 --- a/.github/actions/spell-check/excludes.txt +++ b/.github/actions/spell-check/excludes.txt @@ -105,6 +105,7 @@ ^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$ ^src/common/sysinternals/Eula/ ^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$ +^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$ ^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$ ^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/ ^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$ @@ -121,6 +122,10 @@ ^src/modules/MouseWithoutBorders/App/Helper/.*\.resx$ ^src/modules/MouseWithoutBorders/ModuleInterface/generateSecurityDescriptor\.h$ ^src/modules/peek/Peek.Common/NativeMethods\.txt$ +^src/modules/peek/Peek.UITests/TestAssets/4\.qoi$ +^src/modules/powerrename/PowerRenameUITest/testItems/folder1/testCase2\.txt$ +^src/modules/powerrename/PowerRenameUITest/testItems/folder2/SpecialCase\.txt$ +^src/modules/powerrename/PowerRenameUITest/testItems/testCase1\.txt$ ^src/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator\.cs$ ^src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HelperFiles/MarkdownWithHTMLImageTag\.txt$ ^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$ @@ -131,3 +136,4 @@ ignore$ ^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$ ^src/common/CalculatorEngineCommon/exprtk\.hpp$ +src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 8b9f694e3b..e2abef0344 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -3,6 +3,7 @@ abcdefghjkmnpqrstuvxyz abgr ABlocked ABOUTBOX +ABORTIFHUNG Abug Acceleratorkeys ACCEPTFILES @@ -21,12 +22,12 @@ ADate ADDSTRING ADDUNDORECORD ADifferent +adjacents ADMINS adml admx advancedpaste -advancedpasteui -advancedpasteuishortcut +advapi advfirewall AFeature affordances @@ -34,6 +35,7 @@ AFX AGGREGATABLE AHK AHybrid +AIUI akv ALarger ALIGNRIGHT @@ -43,7 +45,6 @@ ALLINPUT Allman Allmodule ALLOWUNDO -allpc ALLVIEW ALPHATYPE AModifier @@ -64,6 +65,7 @@ APIIs Apm APPBARDATA APPEXECLINK +appext APPLICATIONFRAMEHOST appmanifest APPMODEL @@ -76,6 +78,7 @@ appwiz appxpackage APSTUDIO AQS +Aquadrant ARandom ARCHITEW ARemapped @@ -94,9 +97,10 @@ ASSOCSTR ASYNCWINDOWPLACEMENT ASYNCWINDOWPOS atl +ATX ATRIOX aumid -Authenticode +authenticode AUTOBUDDY AUTOCHECKBOX AUTOHIDE @@ -110,9 +114,13 @@ AValid AWAYMODE azcliversion azman +azureaiinference +azureinference +azureopenai bbwe BCIE bck +backticks BESTEFFORT bezelled bhid @@ -133,14 +141,14 @@ bla BLACKFRAME BLENDFUNCTION Blockquotes -blogs -Blt +blt BLURBEHIND BLURREGION bmi BNumber BODGY BOklab +Bootstrappers BOOTSTRAPPERINSTALLFOLDER BOTTOMALIGN boxmodel @@ -159,6 +167,7 @@ BUILDARCH BUILDNUMBER buildtransitive builttoroam +BUNDLEINFO BVal BValue byapp @@ -167,14 +176,18 @@ BYPOSITION CALCRECT CALG callbackptr +cabstr calpwstr +caub Cangjie CANRENAME +Carlseibert Canvascustomlayout CAPTUREBLT CAPTURECHANGED CARETBLINKING CAtl +CBN cch CCHDEVICENAME CCHFORMNAME @@ -193,9 +206,11 @@ changecursor CHILDACTIVATE CHILDWINDOW CHOOSEFONT +CIBUILD cidl CIELCh cim +claude CImage cla CLASSDC @@ -228,6 +243,7 @@ CODENAME codereview Codespaces Coen +cognitiveservices COINIT colid colorconv @@ -242,11 +258,13 @@ cominterop commandnotfound commandpalette compmgmt +COMPOSITIONDISABLED COMPOSITIONFULL CONFIGW CONFLICTINGMODIFIERKEY CONFLICTINGMODIFIERSHORTCUT CONOUT +coreclr constexpr contentdialog contentfiles @@ -266,10 +284,14 @@ countof covrun cpcontrols cph +cppcoreguidelines cplusplus CPower +cpptools +cppvsdbg cppwinrt createdump +creativecommons CREATEPROCESS CREATESCHEDULEDTASK CREATESTRUCT @@ -279,6 +301,7 @@ CRH critsec cropandlock Crossdevice +csdevkit CSearch CSettings cso @@ -292,6 +315,7 @@ CURRENTDIR CURSORINFO cursorpos CURSORSHOWING +CURSORWRAP customaction CUSTOMACTIONTEST CUSTOMFORMATPLACEHOLDER @@ -331,6 +355,7 @@ Deact debugbreak decryptor Dedup +dfx Deduplicator Deeplink DEFAULTBOOTSTRAPPERINSTALLFOLDER @@ -360,11 +385,12 @@ desktopshorcutinstalled DESKTOPVERTRES devblogs devdocs +devenv devmgmt DEVMODE DEVMODEW devpal -DFX +dfx DIALOGEX digicert DINORMAL @@ -378,6 +404,7 @@ DISPLAYFREQUENCY displayname DISPLAYORIENTATION divyan +djwsxzxb Dlg DLGFRAME DLGMODALFRAME @@ -388,6 +415,10 @@ DNLEN DONOTROUND DONTVALIDATEPATH dotnet +downsampled +downsampling +Downsampled +downscale DPICHANGED DPIs DPSAPI @@ -402,7 +433,7 @@ DROPFILES DSTINVERT DString DSVG -DTo +dto DUMMYUNIONNAME dutil DVASPECT @@ -436,7 +467,7 @@ EDITKEYBOARD EDITSHORTCUTS EDITTEXT EFile -ekus +eku emojis ENABLEDELAYEDEXPANSION ENABLEDPOPUP @@ -447,6 +478,7 @@ encryptor ENDSESSION ENSUREVISIBLE ENTERSIZEMOVE +ENTRYW ENU environmentvariables EOAC @@ -477,6 +509,7 @@ examplehandler examplepowertoy EXAND EXCLUDEFROMCAPTURE +EXECUTEDEFAULT executionpolicy exename exf @@ -498,10 +531,12 @@ EXTRINSICPROPERTIES eyetracker FANCYZONESDRAWLAYOUTTEST FANCYZONESEDITOR +FNumber FARPROC +fdx fesf -fff FFFF +Figma FILEEXPLORER fileexploreraddons fileexplorerpreview @@ -528,6 +563,7 @@ FIXEDSYS flac flyouts FMask +foundrylocal fmtid FOF FOFX @@ -563,8 +599,10 @@ GETDESKWALLPAPER GETDLGCODE GETDPISCALEDSIZE getfilesiginforedist +geolocator GETHOTKEY GETICON +GETLBTEXT GETMINMAXINFO GETNONCLIENTMETRICS GETPROPERTYSTOREFLAGS @@ -572,6 +610,8 @@ GETSCREENSAVERRUNNING GETSECKEY GETSTICKYKEYS GETTEXTLENGTH +GIFs +gitmodules GHND GMEM GNumber @@ -581,6 +621,7 @@ GPOCA gpp gpu gradians +grctlext Gridcustomlayout GSM gtm @@ -590,6 +631,8 @@ GValue gwl GWLP GWLSTYLE +googleai +googlegemini hangeul Hanzi Hardlines @@ -626,6 +669,7 @@ Hiber Hiberboot HIBYTE hicon +HICONSM HIDEREADONLY HIDEWINDOW Hif @@ -659,11 +703,7 @@ Hostx hotfixes hotkeycontrol HOTKEYF -hotkeylockmachine -hotkeyreconnect hotkeys -hotkeyswitch -hotkeytoggleeasymouse hotlight hotspot HPAINTBUFFER @@ -681,6 +721,7 @@ HTCLIENT hthumbnail HTOUCHINPUT HTTRANSPARENT +hutchinsoniana HVal HValue Hvci @@ -702,7 +743,9 @@ IDCANCEL IDD idk idl +IIM idlist +ifd IDOK IDOn IDR @@ -719,11 +762,10 @@ Ijwhost ILD IMAGEHLP IMAGERESIZERCONTEXTMENU +IPTC IMAGERESIZEREXT imageresizerinput imageresizersettings -imagetotext -imagetotextshortcut imagingdevices ime imgflip @@ -739,7 +781,7 @@ INITDIALOG INITGUID INITTOLOGFONTSTRUCT INLINEPREFIX -Inlines +inlines INPC inproc INPUTHARDWARE @@ -812,12 +854,15 @@ keyvault KILLFOCUS killrunner kmph +ksa kvp Kybd +LARGEICON lastcodeanalysissucceeded LASTEXITCODE LAYOUTRTL LCh +lbl lcid LCIDTo lcl @@ -836,10 +881,12 @@ LIBID LIMITSIZE LIMITTEXT lindex +lightswitch linkid LINKOVERLAY LINQTo listview +LIVEDRAW LIVEZOOM LLKH llkhf @@ -851,17 +898,20 @@ localappdata localpackage LOCALSYSTEM LOCATIONCHANGE -LOCKMACHINE LOCKTYPE LOGFONT LOGFONTW logon +lon +LOGMSG LOGPIXELSX LOGPIXELSY -LOn +lng +lon longdate LONGNAMES lowlevel +lquadrant LOWORD lparam LPBITMAPINFOHEADER @@ -908,11 +958,11 @@ luid LUMA lusrmgr LVal -lvm LWA lwin LZero MAGTRANSFORM +makeappx MAKEINTRESOURCE MAKEINTRESOURCEA MAKEINTRESOURCEW @@ -937,7 +987,6 @@ MDL mdtext mdtxt mdwn -measuretool meme memicmp MENUITEMINFO @@ -945,6 +994,7 @@ MENUITEMINFOW MERGECOPY MERGEPAINT Metadatas +metadatamatters metafile mfc Mgmt @@ -987,10 +1037,12 @@ MOUSEHWHEEL MOUSEINPUT mousejump mousepointer -mousepointercrosshairs mouseutils MOVESIZEEND MOVESIZESTART +muxx +muxxc +muxxh MRM MRT mru @@ -1010,6 +1062,7 @@ msiexec MSIFASTINSTALL MSIHANDLE MSIRESTARTMANAGERCONTROL +MSIs msixbundle MSIXCA MSLLHOOKSTRUCT @@ -1104,6 +1157,7 @@ nonstd NOOWNERZORDER NOPARENTNOTIFY NOPREFIX +NPU NOREDIRECTIONBITMAP NOREDRAW NOREMOVE @@ -1140,6 +1194,7 @@ NTSTATUS NTSYSAPI NULLCURSOR nullonfailure +nullref numberbox nwc ocr @@ -1162,6 +1217,9 @@ opencode OPENFILENAME opensource openxmlformats +ollama +Olllama +onnx OPTIMIZEFORINVOKE ORPHANEDDIALOGTITLE ORSCANS @@ -1185,23 +1243,13 @@ PACL PAINTSTRUCT PALETTEWINDOW PARENTNOTIFY +PARENTRELATIVE PARENTRELATIVEEDITING PARENTRELATIVEFORADDRESSBAR +PARENTRELATIVEFORUI PARENTRELATIVEPARSING parray PARTIALCONFIRMATIONDIALOGTITLE -pasteashtmlfile -pasteashtmlfileshortcut -pasteasjson -pasteasjsonshortcut -pasteasmarkdown -pasteasmarkdownshortcut -pasteasplaintext -pasteasplaintextshortcut -pasteaspngfile -pasteaspngfileshortcut -pasteastxtfile -pasteastxtfileshortcut PATCOPY PATHMUSTEXIST PATINVERT @@ -1209,6 +1257,7 @@ PATPAINT pbc pbi PBlob +pbrush pcb pcch pcelt @@ -1242,6 +1291,7 @@ pgp pguid phbm phbmp +phicon phwnd pici pidl @@ -1250,6 +1300,8 @@ pinfo pinvoke pipename PKBDLLHOOKSTRUCT +pkgfamily +PKI plib ploc ploca @@ -1262,6 +1314,7 @@ pnid PNMLINK Poc Podcasts +Photoshop POINTERID POINTERUPDATE Pokedex @@ -1269,7 +1322,6 @@ Pomodoro Popups POPUPWINDOW POSITIONITEM -powerocr POWERRENAMECONTEXTMENU powerrenameinput POWERRENAMETEST @@ -1320,7 +1372,6 @@ PRODUCTVERSION Progman programdata projectname -projitems PROPERTYKEY Propset PROPVARIANT @@ -1328,6 +1379,7 @@ PRTL prvpane psapi pscid +pscustomobject PSECURITY psfgao psfi @@ -1357,6 +1409,8 @@ pwsz pwtd QDC qit +QNN +Qualcomm QITAB QITABENT qoi @@ -1368,8 +1422,9 @@ quickaccent QUNS RAII RAlt +RAquadrant randi -Rasterization +rasterization Rasterize RAWINPUTDEVICE RAWINPUTHEADER @@ -1412,7 +1467,6 @@ Removelnk renamable RENAMEONCOLLISION reparented -reparenthotkey reparenting reportfileaccesses requery @@ -1438,7 +1492,6 @@ RIDEV RIGHTSCROLLBAR riid RKey -Rns RNumber rop ROUNDSMALL @@ -1466,6 +1519,7 @@ sacl safeprojectname SAMEKEYPREVIOUSLYMAPPED SAMESHORTCUTPREVIOUSLYMAPPED +samsung sancov SAVEFAILED scanled @@ -1662,10 +1716,10 @@ STYLECHANGED STYLECHANGING subkeys sublang -Subdomain SUBMODULEUPDATE subresource Superbar +suntimes sut svchost SVGIn @@ -1693,6 +1747,7 @@ syskeydown SYSKEYUP SYSLIB SYSMENU +systemai SYSTEMAPPS SYSTEMMODAL SYSTEMTIME @@ -1731,9 +1786,9 @@ tgz themeresources THH THICKFRAME +THEMECHANGED THISCOMPONENT throughs -thumbnailhotkey TILEDWINDOW TILLSON timedate @@ -1747,10 +1802,9 @@ tkconverters tlb tlbimp tlc +tmain TNP -TOGGLEEASYMOUSE Toolhelp -toolkitconverters toolwindow TOPDOWNDIB TOUCHEVENTF @@ -1762,11 +1816,9 @@ tracelogging tracerpt trackbar trafficmanager -transcodetomp transicc TRAYMOUSEMESSAGE triaging -Tru trl trx tsa @@ -1821,6 +1873,7 @@ UPDATENOW UPDATEREGISTRY updown UPGRADINGPRODUCTCODE +upscaling Uptool urld Usb @@ -1830,6 +1883,7 @@ USEINSTALLERFORTEST USESHOWWINDOW USESTDHANDLES USRDLL +utm UType uuidv uwp @@ -1906,6 +1960,7 @@ wcsicmp wcsncpy wcsnicmp WCT +WCRAPI WDA wdm wdp @@ -1919,6 +1974,7 @@ wgpocpl WHEREID wic wifi +wikimedia wikipedia WIL winapi @@ -1948,6 +2004,8 @@ WINL winlogon winmd WINNT +windowsml +winml winres winrt winsdk @@ -1968,6 +2026,7 @@ WMI WMICIM wmimgmt wmp +wmsg WMSYSCOMMAND wnd WNDCLASS @@ -1981,6 +2040,7 @@ WORKSPACESEDITOR WORKSPACESLAUNCHER WORKSPACESSNAPSHOTTOOL WORKSPACESWINDOWARRANGER +worktree wox wparam wpf @@ -2011,7 +2071,10 @@ XAxis XButton xclip xcopy +xap XDeployment +XDimension +xdf XDocument XElement xfd @@ -2028,6 +2091,7 @@ xsi XSpeed XStr xstyler +xmp XTimer XUP XVIRTUALSCREEN @@ -2035,6 +2099,7 @@ xxxxxx YAxis ycombinator YIncrement +YDimension yinle yinyue YPels diff --git a/.github/actions/spell-check/patterns.txt b/.github/actions/spell-check/patterns.txt index 255a7f984c..181d728e84 100644 --- a/.github/actions/spell-check/patterns.txt +++ b/.github/actions/spell-check/patterns.txt @@ -1,5 +1,10 @@ # See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns +# marker to ignore all code on line +^.*/\* #no-spell-check-line \*/.*$ +# marker for ignoring a comment to the end of the line +// #no-spell-check.*$ + # Gaelic Gàidhlig @@ -248,7 +253,7 @@ _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING # hit-count: 1 file-count: 1 # Amazon -\bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|)[^"'\s]+ +\bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|) # hit-count: 3 file-count: 3 # imgur @@ -264,3 +269,7 @@ St&yle # This matches a relative clause where the relative pronoun "that" is omitted. # Example: "Gets or sets the window the TitleBar should configure." \bthe\s+\w+\s+the\b + +# Usernames with numbers +# 0x6f677548 is user name but user folder causes a flag +\bx6f677548\b diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..bb24140592 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,59 @@ +--- +description: PowerToys AI contributor guidance. +applyTo: pullRequests +--- + +# PowerToys - Copilot guide (concise) + +This is the top-level guide for AI changes. Keep edits small, follow existing patterns, and cite exact paths in PRs. + +# Repo map (1-line per area) +- Core apps: `src/runner/**` (tray/loader), `src/settings-ui/**` (Settings app) +- Shared libs: `src/common/**` +- Modules: `src/modules/*` (one per utility; Command Palette in `src/modules/cmdpal/**`) +- Build tools/docs: `tools/**`, `doc/devdocs/**` + +# Build and test (defaults) +- Prerequisites: Visual Studio 2022 17.4+, minimal Windows 10 1803+. +- Build discipline: + - One terminal per operation (build -> test). Do not switch or open new ones mid-flow. + - After making changes, `cd` to the project folder that changed (`.csproj`/`.vcxproj`). + - Use scripts to build, synchronously block and wait in foreground for completion: `tools/build/build.ps1|.cmd` (current folder), `build-essentials.*` (once per brand new build for missing nuget packages). + - Treat build exit code 0 as success; any non-zero exit code is a failure. Read the errors log in the build folder (such as `build.*.*.errors.log`) and surface problems. + - Do not start tests or launch Runner until the previous step succeeded. +- Tests (fast and targeted): + - Find the test project by product code prefix (for example FancyZones, AdvancedPaste). Look for a sibling folder or one to two levels up named like `*UnitTests` or `*UITests`. + - Build the test project, wait for exit, then run only those tests via VS Test Explorer or `vstest.console.exe` with filters. Avoid `dotnet test` in this repo. + - Add or adjust tests when changing behavior; if skipped, state why (for example comment-only or string rename). + +# Pull requests (expectations) +- Atomic: one logical change; no drive-by refactors. +- Describe: problem, approach, risk, test evidence. +- List: touched paths if not obvious. + +# When to ask for clarification +- Ambiguous spec after scanning relevant docs (see below). +- Cross-module impact (shared enum or struct) not clear. +- Security, elevation, or installer changes. + +# Logging (use existing stacks) +- C++ logging lives in `src/common/logger/**` (`Logger::info`, `Logger::warn`, `Logger::error`, `Logger::debug`). Keep hot paths quiet (hooks, tight loops). +- C# logging goes through `ManagedCommon.Logger` (`LogInfo`, `LogWarning`, `LogError`, `LogDebug`, `LogTrace`). Some UIs use injected `ILogger` via `LoggerInstance.Logger`. + +# Docs to consult +- `tools/build/BUILD-GUIDELINES.md` +- `doc/devdocs/core/architecture.md` +- `doc/devdocs/core/runner.md` +- `doc/devdocs/core/settings/readme.md` +- `doc/devdocs/modules/readme.md` + +# Language style rules +- Always enforce repo analyzers: root `.editorconfig` plus any `stylecop.json`. +- C# code follows StyleCop.Analyzers and Microsoft.CodeAnalysis.NetAnalyzers. +- C++ code honors `.clang-format` plus `.clang-tidy` (modernize/cppcoreguidelines/readability). +- Markdown files wrap at 80 characters and use ATX headers with fenced code blocks that include language tags. +- YAML files indent two spaces and add comments for complex settings while keeping keys clear. +- PowerShell scripts use Verb-Noun names and prefer single-quoted literals while documenting parameters and satisfying PSScriptAnalyzer. + +# Done checklist (self review before finishing) +- Build clean? Tests updated or passed? No unintended formatting? Any new dependency? Documented skips? diff --git a/.github/prompts/create-commit-title.prompt.md b/.github/prompts/create-commit-title.prompt.md new file mode 100644 index 0000000000..5d84aef163 --- /dev/null +++ b/.github/prompts/create-commit-title.prompt.md @@ -0,0 +1,16 @@ +--- +mode: 'agent' +model: GPT-5-Codex (Preview) +description: 'Generate an 80-character git commit title for the local diff.' +--- + +**Goal:** Provide a ready-to-paste git commit title (<= 80 characters) that captures the most important local changes since `HEAD`. + +**Workflow:** +1. Run a single command to view the local diff since the last commit: + ```@terminal + git diff HEAD + ``` +2. From that diff, identify the dominant area (reference key paths like `src/modules/*`, `doc/devdocs/**`, etc.), the type of change (bug fix, docs update, config tweak), and any notable impact. +3. Draft a concise, imperative commit title summarizing the dominant change. Keep it plain ASCII, <= 80 characters, and avoid trailing punctuation. Mention the primary component when obvious (for example `FancyZones:` or `Docs:`). +4. Respond with only the final commit title on a single line so it can be pasted directly into `git commit`. diff --git a/.github/prompts/create-pr-summary.prompt.md b/.github/prompts/create-pr-summary.prompt.md new file mode 100644 index 0000000000..0c324e7578 --- /dev/null +++ b/.github/prompts/create-pr-summary.prompt.md @@ -0,0 +1,22 @@ +--- +mode: 'agent' +model: GPT-5-Codex (Preview) +description: 'Generate a PowerToys-ready pull request description from the local diff.' +--- + +**Goal:** Produce a ready-to-paste PR title and description that follows PowerToys conventions by comparing the current branch against a user-selected target branch. + +**Repo guardrails:** +- Treat `.github/pull_request_template.md` as the single source of truth; load it at runtime instead of embedding hardcoded content in this prompt. +- Preserve section order from the template but only surface checklist lines that are relevant for the detected changes, filling them with `[x]`/`[ ]` as appropriate. +- Cite touched paths with inline backticks, matching the guidance in `.github/copilot-instructions.md`. +- Call out test coverage explicitly: list automated tests run (unit/UI) or state why they are not applicable. + +**Workflow:** +1. Determine the target branch from user context; default to `main` when no branch is supplied. +2. Run `git status --short` once to surface uncommitted files that may influence the summary. +3. Run `git diff ...HEAD` a single time to review the detailed changes. Only when confidence stays low dig deeper with focused calls such as `git diff ...HEAD -- `. +4. From the diff, capture impacted areas, key file changes, behavioral risks, migrations, and noteworthy edge cases. +5. Confirm validation: list tests executed with results or state why tests were skipped in line with repo guidance. +6. Load `.github/pull_request_template.md`, mirror its section order, and populate it with the gathered facts. Include only relevant checklist entries, marking them `[x]/[ ]` and noting any intentional omissions as "N/A". +7. Present the filled template inside a fenced ```markdown code block with no extra commentary so it is ready to paste into a PR, clearly flagging any placeholders that still need user input. diff --git a/.github/prompts/fix-spelling.prompt.md b/.github/prompts/fix-spelling.prompt.md new file mode 100644 index 0000000000..e0007ff724 --- /dev/null +++ b/.github/prompts/fix-spelling.prompt.md @@ -0,0 +1,22 @@ +--- +mode: 'agent' +model: GPT-5-Codex (Preview) +description: 'Resolve Code scanning / check-spelling comments on the active PR.' +--- + +**Goal:** Clear every outstanding GitHub pull request comment created by the `Code scanning / check-spelling` workflow by explicitly allowing intentional terms. + +**Guardrails:** +- Update only discussion threads authored by `github-actions` or `github-actions[bot]` that mention `Code scanning results / check-spelling`. +- Resolve findings solely by editing `.github/actions/spell-check/expect.txt`; reuse existing entries. +- Leave all other files and topics untouched. + +**Prerequisites:** +- Install GitHub CLI if it is not present: `winget install GitHub.cli`. +- Run `gh auth login` once before the first CLI use. + +**Workflow:** +1. Determine the active pull request with a single `gh pr view --json number` call (default to the current branch). +2. Fetch all PR discussion data once via `gh pr view --json comments,reviews` and filter to check-spelling comments authored by `github-actions` or `github-actions[bot]` that are not minimized; when several remain, process only the most recent comment body. +3. For each flagged token, review `.github/actions/spell-check/expect.txt` for an equivalent term (for example an existing lowercase variant); when found, reuse that normalized term rather than adding a new entry, even if the flagged token differs only by casing. Only add a new entry after confirming no equivalent already exists. +4. Add any remaining missing token to `.github/actions/spell-check/expect.txt`, keeping surrounding formatting intact. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 04f9cfaaeb..560b44b5a4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,6 +5,7 @@ ## PR Checklist - [ ] Closes: #xxx + - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized diff --git a/.github/workflows/automatic-issue-deduplication.yml b/.github/workflows/automatic-issue-deduplication.yml new file mode 100644 index 0000000000..88ec3e2f23 --- /dev/null +++ b/.github/workflows/automatic-issue-deduplication.yml @@ -0,0 +1,19 @@ +name: Automatic New Issue Deduplication +on: + issues: + types: [opened, reopened] +permissions: + models: read + issues: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true +jobs: + deduplicate: + runs-on: ubuntu-latest + steps: + - name: Run Deduplicate Action + uses: pelikhan/action-genai-issue-dedup@v0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + label_as_duplicate: true diff --git a/.github/workflows/msstore-submissions.yml b/.github/workflows/msstore-submissions.yml index a44dafb199..36dfc4d785 100644 --- a/.github/workflows/msstore-submissions.yml +++ b/.github/workflows/msstore-submissions.yml @@ -40,7 +40,7 @@ jobs: echo powerToysInstallerArm64Url=$(jq -n "$powerToysSetup" | jq -r '[.[]|select(.name | contains("arm64"))][0].browser_download_url') >> $GITHUB_OUTPUT - name: Setup .NET 9.0 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '9.0.x' diff --git a/.gitignore b/.gitignore index 8859e53742..1318abc22c 100644 --- a/.gitignore +++ b/.gitignore @@ -349,7 +349,6 @@ src/common/Telemetry/*.etl /src/modules/powerrename/ui/RCb24464 # Generated installer file for Monaco source files. -/installer/PowerToysSetup/MonacoSRC.wxs /installer/PowerToysSetupVNext/MonacoSRC.wxs # MSBuildCache diff --git a/.gitmodules b/.gitmodules index 1601291341..f878c1a9e3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,3 @@ [submodule "deps/expected-lite"] path = deps/expected-lite url = https://github.com/martinmoene/expected-lite.git -[submodule "deps/cziplib"] - path = deps/cziplib - url = https://github.com/kuba--/zip.git diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index b7ef4b26b1..be216941e8 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -5,7 +5,6 @@ { "MatchedPath": [ "*.resources.dll", - "WinUI3Apps\\Assets\\Settings\\Scripts\\*.ps1", "PowerToys.ActionRunner.exe", @@ -27,6 +26,7 @@ "PowerToys.GPOWrapper.dll", "PowerToys.GPOWrapperProjection.dll", "PowerToys.AllExperiments.dll", + "LanguageModelProvider.dll", "Common.Search.dll", @@ -55,7 +55,6 @@ "PowerToys.Awake.exe", "PowerToys.Awake.dll", - "PowerToys.FancyZonesEditor.exe", "PowerToys.FancyZonesEditor.dll", "PowerToys.FancyZonesEditorCommon.dll", @@ -140,6 +139,9 @@ "PowerToys.ImageResizerContextMenu.dll", "ImageResizerContextMenuPackage.msix", + "PowerToys.LightSwitchModuleInterface.dll", + "LightSwitchService\\PowerToys.LightSwitchService.exe", + "PowerToys.KeyboardManager.dll", "KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe", "KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe", @@ -185,6 +187,7 @@ "PowerToys.MousePointerCrosshairs.dll", "PowerToys.MouseJumpUI.dll", "PowerToys.MouseJumpUI.exe", + "PowerToys.CursorWrap.dll", "PowerToys.MouseWithoutBorders.dll", "PowerToys.MouseWithoutBorders.exe", @@ -236,7 +239,12 @@ "PowerToys.CmdPalModuleInterface.dll", "CmdPalKeyboardService.dll", - "*Microsoft.CmdPal.UI_*.msix" + "*Microsoft.CmdPal.UI_*.msix", + + "PowerToys.DSC.dll", + "PowerToys.DSC.exe", + + "PowerToysSparse.msix" ], "SigningInfo": { "Operations": [ @@ -289,6 +297,7 @@ "Mono.Cecil.Rocks.dll", "Newtonsoft.Json.dll", "CommunityToolkit.WinUI.Controls.TitleBar.dll", + "CommunityToolkit.WinUI.Controls.OpacityMaskView.dll", "NLog.dll", "HtmlAgilityPack.dll", @@ -303,6 +312,9 @@ "msvcp140_1_app.dll", "msvcp140_2_app.dll", "msvcp140_app.dll", + "Namotion.Reflection.dll", + "NJsonSchema.Annotations.dll", + "NJsonSchema.dll", "vcamp140_app.dll", "vccorlib140_app.dll", "vcomp140_app.dll", @@ -328,6 +340,12 @@ "WinUI3Apps\\ReverseMarkdown.dll", "WinUI3Apps\\SharpCompress.dll", "WinUI3Apps\\ZstdSharp.dll", + "CommunityToolkit.WinUI.Controls.MarkdownTextBlock.dll", + "WinUI3Apps\\CommunityToolkit.WinUI.Controls.MarkdownTextBlock.dll", + "Markdig.dll", + "WinUI3Apps\\Markdig.dll", + "RomanNumerals.dll", + "WinUI3Apps\\RomanNumerals.dll", "TestableIO.System.IO.Abstractions.dll", "WinUI3Apps\\TestableIO.System.IO.Abstractions.dll", "TestableIO.System.IO.Abstractions.Wrappers.dll", @@ -336,6 +354,8 @@ "Testably.Abstractions.FileSystem.Interface.dll", "WinUI3Apps\\Testably.Abstractions.FileSystem.Interface.dll", "ColorCode.Core.dll", + "Microsoft.SemanticKernel.Connectors.Ollama.dll", + "OllamaSharp.dll", "UnitsNet.dll", "UtfUnknown.dll", diff --git a/.pipelines/ESRPSigning_installer.json b/.pipelines/ESRPSigning_installer.json deleted file mode 100644 index cd96fb6f64..0000000000 --- a/.pipelines/ESRPSigning_installer.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "Version": "1.0.0", - "UseMinimatch": false, - "SignBatches": [ - { - "MatchedPath": [ - "PowerToysSetupCustomActions.dll", - "PowerToysSetupCustomActionsVNext.dll", - "SilentFilesInUseBAFunction.dll", - "PowerToys*Setup-*.exe", - "PowerToys*Setup-*.msi" - ], - "SigningInfo": { - "Operations": [ - { - "KeyCode": "CP-230012", - "OperationSetCode": "SigntoolSign", - "Parameters": [ - { - "parameterName": "OpusName", - "parameterValue": "Microsoft" - }, - { - "parameterName": "OpusInfo", - "parameterValue": "http://www.microsoft.com" - }, - { - "parameterName": "FileDigest", - "parameterValue": "/fd \"SHA256\"" - }, - { - "parameterName": "PageHash", - "parameterValue": "/NPH" - }, - { - "parameterName": "TimeStamp", - "parameterValue": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" - } - ], - "ToolName": "sign", - "ToolVersion": "1.0" - }, - { - "KeyCode": "CP-230012", - "OperationSetCode": "SigntoolVerify", - "Parameters": [], - "ToolName": "sign", - "ToolVersion": "1.0" - } - ] - } - } - ] -} diff --git a/.pipelines/generateDscManifests.ps1 b/.pipelines/generateDscManifests.ps1 new file mode 100644 index 0000000000..e0a2f463af --- /dev/null +++ b/.pipelines/generateDscManifests.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BuildPlatform, + + [Parameter(Mandatory = $true)] + [string]$BuildConfiguration, + + [Parameter()] + [string]$RepoRoot = (Get-Location).Path +) + +$ErrorActionPreference = 'Stop' + +function Resolve-PlatformDirectory { + param( + [string]$Root, + [string]$Platform + ) + + $normalized = $Platform.Trim() + $candidates = @() + $candidates += Join-Path $Root $normalized + $candidates += Join-Path $Root ($normalized.ToUpperInvariant()) + $candidates += Join-Path $Root ($normalized.ToLowerInvariant()) + $candidates = $candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique + + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + return $candidates[0] +} + +Write-Host "Repo root: $RepoRoot" +Write-Host "Requested build platform: $BuildPlatform" +Write-Host "Requested configuration: $BuildConfiguration" + +# Always use x64 PowerToys.DSC.exe since CI/CD machines are x64 +$exePlatform = 'x64' +$exeRoot = Resolve-PlatformDirectory -Root $RepoRoot -Platform $exePlatform +$exeOutputDir = Join-Path $exeRoot $BuildConfiguration +$exePath = Join-Path $exeOutputDir 'PowerToys.DSC.exe' + +Write-Host "Using x64 PowerToys.DSC.exe to generate DSC manifests for $BuildPlatform build" + +if (-not (Test-Path $exePath)) { + throw "PowerToys.DSC.exe not found at '$exePath'. Make sure it has been built first." +} + +Write-Host "Using PowerToys.DSC.exe at '$exePath'." + +# Output DSC manifests to the target build platform directory (x64, ARM64, etc.) +$outputRoot = Resolve-PlatformDirectory -Root $RepoRoot -Platform $BuildPlatform +if (-not (Test-Path $outputRoot)) { + Write-Host "Creating missing platform output root at '$outputRoot'." + New-Item -Path $outputRoot -ItemType Directory -Force | Out-Null +} + +$outputDir = Join-Path $outputRoot $BuildConfiguration +if (-not (Test-Path $outputDir)) { + Write-Host "Creating missing configuration output directory at '$outputDir'." + New-Item -Path $outputDir -ItemType Directory -Force | Out-Null +} + +# DSC v3 manifests go to DSCModules subfolder +$dscOutputDir = Join-Path $outputDir 'DSCModules' +if (-not (Test-Path $dscOutputDir)) { + Write-Host "Creating DSCModules subfolder at '$dscOutputDir'." + New-Item -Path $dscOutputDir -ItemType Directory -Force | Out-Null +} + +Write-Host "DSC manifests will be generated to: '$dscOutputDir'" + +Write-Host "Cleaning previously generated DSC manifest files from '$dscOutputDir'." +Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction SilentlyContinue | Remove-Item -Force + +$arguments = @('manifest', '--resource', 'settings', '--outputDir', $dscOutputDir) +Write-Host "Invoking DSC manifest generator: '$exePath' $($arguments -join ' ')" +& $exePath @arguments +if ($LASTEXITCODE -ne 0) { + throw "PowerToys.DSC.exe exited with code $LASTEXITCODE" +} + +$generatedFiles = Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction Stop +if ($generatedFiles.Count -eq 0) { + throw "No DSC manifest files were generated in '$dscOutputDir'." +} + +Write-Host "Generated $($generatedFiles.Count) DSC manifest file(s):" +foreach ($file in $generatedFiles) { + Write-Host " - $($file.FullName)" +} diff --git a/.pipelines/installWiX.ps1 b/.pipelines/installWiX.ps1 deleted file mode 100644 index 3b6d783c85..0000000000 --- a/.pipelines/installWiX.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -$ProgressPreference = 'SilentlyContinue' - -$WixDownloadUrl = "https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe" -$WixBinariesDownloadUrl = "https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip" - -# Download WiX binaries and verify their hash sums -Invoke-WebRequest -Uri $WixDownloadUrl -OutFile "$($ENV:Temp)\wix314.exe" -$Hash = (Get-FileHash -Algorithm SHA256 "$($ENV:Temp)\wix314.exe").Hash -if ($Hash -ne '6BF6D03D6923D9EF827AE1D943B90B42B8EBB1B0F68EF6D55F868FA34C738A29') -{ - Write-Error "$WixHash" - throw "wix314.exe has unexpected SHA256 hash: $Hash" -} -Invoke-WebRequest -Uri $WixBinariesDownloadUrl -OutFile "$($ENV:Temp)\wix314-binaries.zip" -$Hash = (Get-FileHash -Algorithm SHA256 "$($ENV:Temp)\wix314-binaries.zip").Hash -if($Hash -ne '6AC824E1642D6F7277D0ED7EA09411A508F6116BA6FAE0AA5F2C7DAA2FF43D31') -{ - throw "wix314-binaries.zip has unexpected SHA256 hash: $Hash" -} - -# Install WiX -Start-Process -Wait -FilePath "$($ENV:Temp)\wix314.exe" -ArgumentList "/install /quiet" - -# Extract WiX binaries and copy wix.targets to the installed dir -Expand-Archive -Path "$($ENV:Temp)\wix314-binaries.zip" -Force -DestinationPath "$($ENV:Temp)" -Copy-Item -Path "$($ENV:Temp)\wix.targets" -Destination "C:\Program Files (x86)\WiX Toolset v3.14\" \ No newline at end of file diff --git a/.pipelines/loc/loc.yml b/.pipelines/loc/loc.yml index cc4512c92e..2abc298652 100644 --- a/.pipelines/loc/loc.yml +++ b/.pipelines/loc/loc.yml @@ -29,8 +29,8 @@ steps: displayName: 'Touchdown Build - 37400, PRODEXT' inputs: teamId: 37400 - TDBuildServiceConnection: $(TouchdownServiceConnection) - authType: SubjectNameIssuer + FederatedIdentityTDBuildServiceConnection: $(TouchdownServiceConnection) + authType: FederatedIdentityTDBuild resourceFilePath: | src\**\Resources.resx src\**\Resource.resx diff --git a/.pipelines/v2/ci-nightly.yml b/.pipelines/v2/ci-nightly.yml new file mode 100644 index 0000000000..1f49359f66 --- /dev/null +++ b/.pipelines/v2/ci-nightly.yml @@ -0,0 +1,38 @@ +# .pipelines/v2/nightly-prewarm.yml +# Nightly pre-warm that reuses your existing ci.yml as-is + +trigger: none +pr: none + +# (18:00 UTC) — adjust as you like +schedules: + - cron: "0 18 * * *" # UTC + displayName: Nightly pre-warm (main) + branches: + include: + - main + always: true + +name: $(BuildDefinitionName)_$(date:yyMM).$(date:dd)$(rev:rrr) + +parameters: + - name: buildPlatforms + type: object + default: + - x64 + - arm64 + - name: enableMsBuildCaching + type: boolean + displayName: "Enable MSBuild Caching" + default: true + - name: msBuildCacheIsReadOnly + type: boolean + displayName: "MSBuild Cache Read Only" + default: false + +extends: + template: templates/pipeline-ci-build.yml + parameters: + buildPlatforms: ${{ parameters.buildPlatforms }} + enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + msBuildCacheIsReadOnly: ${{ parameters.msBuildCacheIsReadOnly }} \ No newline at end of file diff --git a/.pipelines/v2/ci.yml b/.pipelines/v2/ci.yml index 6b0105a38a..297c268757 100644 --- a/.pipelines/v2/ci.yml +++ b/.pipelines/v2/ci.yml @@ -32,7 +32,7 @@ parameters: - name: enableMsBuildCaching type: boolean displayName: "Enable MSBuild Caching" - default: true + default: false - name: runTests type: boolean displayName: "Run Tests" diff --git a/.pipelines/v2/release.yml b/.pipelines/v2/release.yml index e13792d8d1..71f80f574b 100644 --- a/.pipelines/v2/release.yml +++ b/.pipelines/v2/release.yml @@ -20,11 +20,6 @@ parameters: type: string default: '0.0.1' - - name: installerSuffix - type: string - displayName: "WiX5 installer suffix (e.g., 'wix5', 'vnext', etc.)" - default: "wix5" - - name: buildConfigurations displayName: "Build Configurations" type: object @@ -43,11 +38,6 @@ parameters: displayName: "Build Using Visual Studio Preview" default: false - - name: enableAOT - type: boolean - displayName: "Enable AOT (Ahead-of-Time) Compilation for CmdPal" - default: true - name: $(BuildDefinitionName)_$(date:yyMM).$(date:dd)$(rev:rrr) variables: @@ -62,8 +52,6 @@ extends: name: SHINE-INT-S ${{ if eq(parameters.useVSPreview, true) }}: demands: ImageOverride -equals SHINE-VS17-Preview - ${{ else }}: - image: SHINE-VS17-Latest os: windows sdl: tsa: @@ -83,10 +71,10 @@ extends: parameters: pool: name: SHINE-INT-L - ${{ if eq(parameters.useVSPreview, true) }}: - demands: ImageOverride -equals SHINE-VS17-Preview - ${{ else }}: - image: SHINE-VS17-Latest + demands: + # Our INT agents have a large disk mounted at P:\ + - ${{ if eq(parameters.useVSPreview, true) }}: + - ImageOverride -equals SHINE-VS17-Preview os: windows variables: IsPipeline: 1 # The installer uses this to detect whether it should pick up localizations @@ -109,8 +97,7 @@ extends: useManagedIdentity: $(SigningUseManagedIdentity) clientId: $(SigningOriginalClientId) # Have msbuild use the release nuget config profile - additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" /p:EnableCmdPalAOT=${{ parameters.enableAOT }} /p:InstallerSuffix=${{ parameters.installerSuffix }} - installerSuffix: ${{ parameters.installerSuffix }} + additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" /p:EnableCmdPalAOT=true beforeBuildSteps: # Sets versions for all PowerToy created DLLs - pwsh: |- @@ -136,7 +123,6 @@ extends: parameters: pool: name: SHINE-INT-L - image: SHINE-VS17-Latest os: windows official: true codeSign: true diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index e345cdc3fa..62cb993d5d 100644 --- a/.pipelines/v2/templates/job-build-project.yml +++ b/.pipelines/v2/templates/job-build-project.yml @@ -50,6 +50,9 @@ parameters: - name: enableMsBuildCaching type: boolean default: false + - name: msBuildCacheIsReadOnly + type: boolean + default: true - name: runTests type: boolean default: true @@ -62,9 +65,6 @@ parameters: - name: versionNumber type: string default: '0.0.1' - - name: installerSuffix - type: string - default: "wix5" - name: useLatestWinAppSDK type: boolean default: false @@ -111,6 +111,7 @@ jobs: ${{ else }}: OutputBuildPlatform: ${{ platform }} variables: + NUGET_PACKAGES: 'C:\NuGetPackages' # Some of our build steps cache these here... and it was apparently part of the global environment MakeAppxPath: 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x86\MakeAppx.exe' # Azure DevOps abhors a vacuum # If these are blank, expansion will fail later on... which will result in direct substitution of the variable *names* @@ -139,6 +140,10 @@ jobs: - output: pipelineArtifact artifactName: $(JobOutputArtifactName) targetPath: $(Build.ArtifactStagingDirectory) + - output: pipelineArtifact + artifactName: $(JobOutputArtifactName)-failure-$(System.JobAttempt) + targetPath: $(LogOutputDirectory) + condition: or(failed(), canceled()) steps: - checkout: self clean: true @@ -154,6 +159,11 @@ jobs: $MSBuildCacheParameters += " -reportfileaccesses" $MSBuildCacheParameters += " -p:MSBuildCacheEnabled=true" $MSBuildCacheParameters += " -p:MSBuildCacheLogDirectory=$(LogOutputDirectory)\MSBuildCacheLogs" + # Cache read-only policy controlled by parameter + $cacheIsReadOnly = "${{ parameters.msBuildCacheIsReadOnly }}" + if ($cacheIsReadOnly -eq "True") { + $MSBuildCacheParameters += " /p:MSBuildCacheRemoteCacheIsReadOnly=true" + } Write-Host "MSBuildCacheParameters: $MSBuildCacheParameters" Write-Host "##vso[task.setvariable variable=MSBuildCacheParameters]$MSBuildCacheParameters" displayName: Prepare MSBuildCache variables @@ -232,9 +242,7 @@ jobs: parameters: directory: $(build.sourcesdirectory)\src\modules\cmdpal - - pwsh: |- - & "$(build.sourcesdirectory)\.pipelines\installWiX.ps1" - displayName: Download and install WiX 3.14 development build + - ${{ parameters.beforeBuildSteps }} @@ -263,6 +271,43 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + - task: VSBuild@1 + displayName: Generate DSC artifacts for ARM64 + condition: and(succeeded(), eq(variables['BuildPlatform'], 'arm64')) + inputs: + solution: PowerToys.sln + vsVersion: 17.0 + msbuildArgs: >- + -restore + /p:Configuration=$(BuildConfiguration) + /p:Platform=x64 + /t:DSC\PowerToys_Settings_DSC_Schema_Generator + /bl:$(LogOutputDirectory)\build-dsc-generator.binlog + ${{ parameters.additionalBuildOptions }} + $(MSBuildCacheParameters) + $(RestoreAdditionalProjectSourcesArg) + platform: x64 + configuration: $(BuildConfiguration) + msbuildArchitecture: x64 + maximumCpuCount: true + + # Build PowerToys.DSC.exe for ARM64 (x64 uses existing binary from previous build) + - task: VSBuild@1 + displayName: Build PowerToys.DSC.exe (x64 for generating manifests) + condition: and(succeeded(), ne(variables['BuildPlatform'], 'x64')) + inputs: + solution: src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj + msbuildArgs: /t:Build /m /restore + platform: x64 + configuration: $(BuildConfiguration) + msbuildArchitecture: x64 + maximumCpuCount: true + + # Generate DSC manifests using PowerToys.DSC.exe + - pwsh: |- + & '.pipelines/generateDscManifests.ps1' -BuildPlatform '$(BuildPlatform)' -BuildConfiguration '$(BuildConfiguration)' -RepoRoot '$(Build.SourcesDirectory)' + displayName: Generate DSC manifests + - task: CopyFiles@2 displayName: Stage SDK/build inputs: @@ -355,7 +400,7 @@ jobs: ### HACK: On ARM64 builds, building an app with Windows App SDK copies the x64 WebView2 dll instead of the ARM64 one. This task makes sure the right dll is used. - task: CopyFiles@2 displayName: HACK Copy core WebView2 ARM64 dll to output directory - condition: eq(variables['BuildPlatform'],'arm64') + condition: and(succeeded(), eq(variables['BuildPlatform'], 'arm64')) inputs: contents: packages/Microsoft.Web.WebView2.1.0.2903.40/runtimes/win-ARM64/native_uap/Microsoft.Web.WebView2.Core.dll targetFolder: $(Build.SourcesDirectory)/ARM64/Release/WinUI3Apps/ @@ -394,11 +439,11 @@ jobs: inputs: testResultsFormat: VSTest testResultsFiles: '**/*.trx' - condition: ne(variables['BuildPlatform'],'arm64') + condition: and(succeeded(), ne(variables['BuildPlatform'], 'arm64')) # Native dlls - task: VSTest@2 - condition: ne(variables['BuildPlatform'],'arm64') # No arm64 agents to run the tests. + condition: and(succeeded(), ne(variables['BuildPlatform'], 'arm64')) # No arm64 agents to run the tests. displayName: 'Native Tests' inputs: platform: '$(BuildPlatform)' @@ -411,9 +456,28 @@ jobs: !**\obj\** - pwsh: |- - $Package = (Get-ChildItem -Recurse -Filter "Microsoft.CmdPal.UI_*.msix" | Select -First 1) - $PackageFilename = $Package.FullName - Write-Host "##vso[task.setvariable variable=CmdPalPackagePath]${PackageFilename}" + $Packages = Get-ChildItem -Recurse -Filter "Microsoft.CmdPal.UI_*.msix" + Write-Host "Found $($Packages.Count) CmdPal MSIX package(s):" + foreach ($pkg in $Packages) { + Write-Host " - $($pkg.FullName)" + } + + if ($Packages.Count -gt 0) { + # Priority: Look for platform-specific MSIX (x64/arm64) first, then fall back to any + $PlatformPackage = $Packages | Where-Object { $_.Name -match "Microsoft\.CmdPal\.UI_.*_(x64|arm64)\.msix$" } | Select-Object -First 1 + if ($PlatformPackage) { + $Package = $PlatformPackage + Write-Host "Using platform-specific package: $($Package.FullName)" + } else { + $Package = $Packages | Select-Object -First 1 + Write-Host "Using first available package: $($Package.FullName)" + } + + $PackageFilename = $Package.FullName + Write-Host "##vso[task.setvariable variable=CmdPalPackagePath]${PackageFilename}" + } else { + Write-Warning "No CmdPal MSIX packages found!" + } displayName: Locate the CmdPal MSIX - ${{ if eq(parameters.codeSign, true) }}: @@ -464,20 +528,7 @@ jobs: Copy-Item -Verbose -Force "$(CmdPalPackagePath)" "$(JobOutputDirectory)" displayName: Stage the final CmdPal package - - template: steps-build-installer.yml - parameters: - codeSign: ${{ parameters.codeSign }} - signingIdentity: ${{ parameters.signingIdentity }} - versionNumber: ${{ parameters.versionNumber }} - additionalBuildOptions: ${{ parameters.additionalBuildOptions }} - - template: steps-build-installer.yml - parameters: - codeSign: ${{ parameters.codeSign }} - signingIdentity: ${{ parameters.signingIdentity }} - versionNumber: ${{ parameters.versionNumber }} - additionalBuildOptions: ${{ parameters.additionalBuildOptions }} - buildUserInstaller: true # NOTE: This is the distinction between the above and below rules - template: steps-build-installer-vnext.yml parameters: @@ -485,16 +536,6 @@ jobs: signingIdentity: ${{ parameters.signingIdentity }} versionNumber: ${{ parameters.versionNumber }} additionalBuildOptions: ${{ parameters.additionalBuildOptions }} - installerSuffix: ${{ parameters.installerSuffix }} - - - template: steps-build-installer-vnext.yml - parameters: - codeSign: ${{ parameters.codeSign }} - signingIdentity: ${{ parameters.signingIdentity }} - versionNumber: ${{ parameters.versionNumber }} - additionalBuildOptions: ${{ parameters.additionalBuildOptions }} - installerSuffix: ${{ parameters.installerSuffix }} - buildUserInstaller: true # NOTE: This is the distinction between the above and below rules # This saves ~1GiB per architecture. We won't need these later. # Removes: @@ -520,29 +561,29 @@ jobs: flattenFolders: True targetFolder: $(JobOutputDirectory) - - task: CopyFiles@2 - displayName: Stage Symbols - inputs: - contents: |- - **\*.pdb - !**\vc143.pdb - !**\*test*.pdb - flattenFolders: True - targetFolder: $(JobOutputDirectory)/symbols-$(BuildPlatform)/ + - pwsh: |- + $Symbols = Get-ChildItem "$(BuildPlatform)" -Recurse -Filter *.pdb -Exclude "vc143.pdb","*test*.pdb" | + Group-Object Name | ForEach-Object { $_.Group[0] } + $OutDir = "$(JobOutputDirectory)/symbols-$(BuildPlatform)" + New-Item -Type Directory $OutDir -EA:Ignore + Write-Host "Linking $($Symbols.Length) symbols into place at $OutDir" + ForEach($s in $Symbols) { + New-Item -Type HardLink -Target $s.FullName (Join-Path $OutDir $s.Name) + } + displayName: Stage Unique Symbols (as hard links) - pwsh: |- $p = "$(JobOutputDirectory)\" - $installerSuffix = "${{ parameters.installerSuffix }}" - # Calculate hashes for regular installers (without custom suffix) - $userSetupFiles = Get-ChildItem -Path $p -Filter "PowerToysUserSetup*.exe" | Where-Object { $_.Name -notmatch "-$installerSuffix-" } - $machineSetupFiles = Get-ChildItem -Path $p -Filter "PowerToysSetup*.exe" | Where-Object { $_.Name -notmatch "-$installerSuffix-" -and $_.Name -notmatch "PowerToysUserSetup" } + # Calculate hashes for installers + $userSetupFiles = Get-ChildItem -Path $p -Filter "PowerToysUserSetup*.exe" + $machineSetupFiles = Get-ChildItem -Path $p -Filter "PowerToysSetup*.exe" | Where-Object { $_.Name -notmatch "PowerToysUserSetup" } if ($userSetupFiles.Count -gt 0) { $userHash = ($userSetupFiles[0] | Get-FileHash).Hash; $userPlat = "hash_user_$(BuildPlatform).txt"; $combinedUserPath = $p + $userPlat; - echo "Regular User: $userHash" + echo "User: $userHash" $userHash | out-file -filepath $combinedUserPath } @@ -550,48 +591,37 @@ jobs: $machineHash = ($machineSetupFiles[0] | Get-FileHash).Hash; $machinePlat = "hash_machine_$(BuildPlatform).txt"; $combinedMachinePath = $p + $machinePlat; - echo "Regular Machine: $machineHash" + echo "Machine: $machineHash" $machineHash | out-file -filepath $combinedMachinePath } - - # Calculate hashes for VNext installers (with custom suffix) - $userVNextFiles = Get-ChildItem -Path $p -Filter "PowerToysUserSetup*-$installerSuffix-*.exe" - $machineVNextFiles = Get-ChildItem -Path $p -Filter "PowerToysSetup*-$installerSuffix-*.exe" | Where-Object { $_.Name -notmatch "PowerToysUserSetup" } - - if ($userVNextFiles.Count -gt 0) { - $userVNextHash = ($userVNextFiles[0] | Get-FileHash).Hash; - $userVNextPlat = "hash_user_vnext_$(BuildPlatform).txt"; - $combinedUserVNextPath = $p + $userVNextPlat; - echo "VNext User: $userVNextHash" - $userVNextHash | out-file -filepath $combinedUserVNextPath - } - - if ($machineVNextFiles.Count -gt 0) { - $machineVNextHash = ($machineVNextFiles[0] | Get-FileHash).Hash; - $machineVNextPlat = "hash_machine_vnext_$(BuildPlatform).txt"; - $combinedMachineVNextPath = $p + $machineVNextPlat; - echo "VNext Machine: $machineVNextHash" - $machineVNextHash | out-file -filepath $combinedMachineVNextPath - } displayName: Calculate file hashes for all installers # Publishing the GPO files - pwsh: |- - New-Item "$(JobOutputDirectory)/gpo" -Type Directory - Copy-Item src\gpo\assets\* "$(JobOutputDirectory)/gpo" -Recurse + $GpoArchive = "$(JobOutputDirectory)\GroupPolicyObjectFiles-${{ parameters.versionNumber }}.zip" + tar -c -v --format=zip -C .\src\gpo\assets -f $GpoArchive * displayName: Stage GPO files - # Running the tests may result in future jobs consuming artifacts out of this build - ${{ if or(eq(parameters.runTests, true), eq(parameters.buildTests, true)) }}: - - task: CopyFiles@2 - displayName: Stage entire build output - inputs: - sourceFolder: '$(Build.SourcesDirectory)' - contents: '$(BuildPlatform)/$(BuildConfiguration)/**/*' - targetFolder: '$(JobOutputDirectory)\$(BuildPlatform)\$(BuildConfiguration)' + # Running the tests may result in future jobs consuming artifacts out of this build + # Instead of running an expensive file copy step, move everything over since the build is totally done. + - pwsh: |- + # It seems weird, but this is for compatibility. Our artifacts historically contained the folder x64/Release/x64/Release (for example). + $FinalOutputRoot = "$(JobOutputDirectory)\$(BuildPlatform)\$(BuildConfiguration)\$(BuildPlatform)" + $ProjectBuildRoot = "$(Build.SourcesDirectory)\$(BuildPlatform)" + $ProjectBuildDirectory = "$ProjectBuildRoot\$(BuildConfiguration)" + + New-Item -Type Directory $FinalOutputRoot -EA:Ignore + Move-Item $ProjectBuildDirectory $FinalOutputRoot + displayName: Move entire output directory into artifacts - ${{ if eq(parameters.publishArtifacts, true) }}: - publish: $(JobOutputDirectory) artifact: $(JobOutputArtifactName) displayName: Publish all outputs - condition: always() + condition: succeeded() + + - publish: $(JobOutputDirectory) + artifact: $(JobOutputArtifactName)-failure-$(System.JobAttempt) + displayName: Publish failure logs + condition: or(failed(), canceled()) diff --git a/.pipelines/v2/templates/pipeline-ci-build.yml b/.pipelines/v2/templates/pipeline-ci-build.yml index 541aff4845..30c1dbc757 100644 --- a/.pipelines/v2/templates/pipeline-ci-build.yml +++ b/.pipelines/v2/templates/pipeline-ci-build.yml @@ -13,6 +13,9 @@ parameters: - name: enableMsBuildCaching type: boolean default: false + - name: msBuildCacheIsReadOnly + type: boolean + default: true - name: runTests type: boolean default: true @@ -52,6 +55,7 @@ stages: buildConfigurations: [Release] enablePackageCaching: true enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + msBuildCacheIsReadOnly: ${{ parameters.msBuildCacheIsReadOnly }} runTests: ${{ parameters.runTests }} useVSPreview: ${{ parameters.useVSPreview }} useLatestWinAppSDK: ${{ parameters.useLatestWinAppSDK }} diff --git a/.pipelines/v2/templates/steps-build-installer-vnext.yml b/.pipelines/v2/templates/steps-build-installer-vnext.yml index 0f7908fceb..71a698b219 100644 --- a/.pipelines/v2/templates/steps-build-installer-vnext.yml +++ b/.pipelines/v2/templates/steps-build-installer-vnext.yml @@ -2,9 +2,6 @@ parameters: - name: versionNumber type: string default: "0.0.1" - - name: buildUserInstaller - type: boolean - default: false - name: codeSign type: boolean default: false @@ -14,9 +11,6 @@ parameters: - name: additionalBuildOptions type: string default: '' - - name: installerSuffix - type: string - default: "wix5" steps: # Install WiX 5.0.2 tools needed for VNext installer (matching project SDK) @@ -28,44 +22,26 @@ steps: arguments: 'install --global wix --version 5.0.2' - pwsh: |- - & git clean -xfd -e *exe -- .\installer\ - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Clean installer to reduce cross-contamination - - - pwsh: |- - # Determine whether this is a per-user build - $IsPerUser = $${{ parameters.buildUserInstaller }} - - # Build slug used to locate the artifacts - $InstallerBuildSlug = if ($IsPerUser) { 'UserSetup' } else { 'MachineSetup' } - - # VNext bundle folder; base name intentionally omits the VNext suffix - $InstallerFolder = 'PowerToysSetupVNext' - if ($IsPerUser) { - $InstallerBasename = "PowerToysUserSetup-${{ parameters.versionNumber }}-${{ parameters.installerSuffix }}-$(BuildPlatform)" - } - else { - $InstallerBasename = "PowerToysSetup-${{ parameters.versionNumber }}-${{ parameters.installerSuffix }}-$(BuildPlatform)" - } - - # Export variables for downstream steps - Write-Host "##vso[task.setvariable variable=InstallerBuildSlug]$InstallerBuildSlug" - Write-Host "##vso[task.setvariable variable=InstallerRelativePath]$(BuildPlatform)\$(BuildConfiguration)\$InstallerBuildSlug" - Write-Host "##vso[task.setvariable variable=InstallerBasename]$InstallerBasename" - Write-Host "##vso[task.setvariable variable=InstallerFolder]$InstallerFolder" - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Prepare Installer variables + Write-Host "##vso[task.setvariable variable=InstallerMachineRoot]installer\PowerToysSetupVNext\$(BuildPlatform)\$(BuildConfiguration)\MachineSetup" + Write-Host "##vso[task.setvariable variable=InstallerUserRoot]installer\PowerToysSetupVNext\$(BuildPlatform)\$(BuildConfiguration)\UserSetup" + Write-Host "##vso[task.setvariable variable=InstallerMachineBasename]PowerToysSetup-${{ parameters.versionNumber }}-$(BuildPlatform)" + Write-Host "##vso[task.setvariable variable=InstallerUserBasename]PowerToysUserSetup-${{ parameters.versionNumber }}-$(BuildPlatform)" + displayName: Prepare Installer variables # This dll needs to be built and signed before building the MSI. + # The Custom Actions project contains a pre-build event that prepares the .wxs files + # by filling them out with all our components. We pass RunBuildEvents=true to force + # that logic to run. - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build PowerToysSetupCustomActionsVNext + displayName: Build Shared Support DLLs inputs: solution: "**/installer/PowerToysSetup.sln" vsVersion: 17.0 msbuildArgs: >- - /t:PowerToysSetupCustomActionsVNext - /p:RunBuildEvents=true;PerUser=${{parameters.buildUserInstaller}};RestorePackagesConfig=true;CIBuild=true - /p:InstallerSuffix=${{ parameters.installerSuffix }} + /t:PowerToysSetupCustomActionsVNext;SilentFilesInUseBAFunction + /p:RunBuildEvents=true;RestorePackagesConfig=true;CIBuild=true -restore -graph - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-actions.binlog + /bl:$(LogOutputDirectory)\installer-actions.binlog ${{ parameters.additionalBuildOptions }} platform: $(BuildPlatform) configuration: $(BuildConfiguration) @@ -74,29 +50,53 @@ steps: maximumCpuCount: true - ${{ if eq(parameters.codeSign, true) }}: - - template: steps-esrp-signing.yml + - template: steps-esrp-sign-files-authenticode.yml parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign PowerToysSetupCustomActionsVNext + displayName: Sign Shared Support DLLs signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/PowerToysSetupCustomActionsVNext/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' + folder: 'installer' + pattern: |- + **/PowerToysSetupCustomActionsVNext.dll + **/SilentFilesInUseBAFunction.dll ## INSTALLER START #### MSI BUILDING AND SIGNING + # + # The MSI build contains code that reverts the .wxs files to their in-tree versions. + # This is only supposed to happen during local builds. Since this build system is + # supposed to run side by side--machine and then user--we do NOT want to destroy + # the .wxs files. Therefore, we pass RunBuildEvents=false to suppress all of that + # logic. + # + # We pass BuildProjectReferences=false so that it does not recompile the DLLs we just built. + # We only pass -restore on the first one because the second run should already have all + # of the dependencies. - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build VNext MSI + displayName: 💻 Build VNext MSI inputs: solution: "**/installer/PowerToysSetup.sln" vsVersion: 17.0 msbuildArgs: >- -restore /t:PowerToysInstallerVNext - /p:RunBuildEvents=false;PerUser=${{parameters.buildUserInstaller}};BuildProjectReferences=false;CIBuild=true - /p:InstallerSuffix=${{ parameters.installerSuffix }} - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-msi.binlog + /p:RunBuildEvents=false;PerUser=false;BuildProjectReferences=false;CIBuild=true + /bl:$(LogOutputDirectory)\installer-machine-msi.binlog + ${{ parameters.additionalBuildOptions }} + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + clean: false # don't undo our hard work above by deleting the CustomActions dll + msbuildArchitecture: x64 + maximumCpuCount: true + + - task: VSBuild@1 + displayName: 👤 Build VNext MSI + inputs: + solution: "**/installer/PowerToysSetup.sln" + vsVersion: 17.0 + msbuildArgs: >- + /t:PowerToysInstallerVNext + /p:RunBuildEvents=false;PerUser=true;BuildProjectReferences=false;CIBuild=true + /bl:$(LogOutputDirectory)\installer-user-msi.binlog ${{ parameters.additionalBuildOptions }} platform: $(BuildPlatform) configuration: $(BuildConfiguration) @@ -105,104 +105,111 @@ steps: maximumCpuCount: true - script: |- - wix msi decompile installer\$(InstallerFolder)\$(InstallerRelativePath)\$(InstallerBasename).msi -x $(build.sourcesdirectory)\extractedMsi - dir $(build.sourcesdirectory)\extractedMsi - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} WiX5: Extract and verify MSI" + wix msi decompile $(InstallerMachineRoot)\$(InstallerMachineBasename).msi -x $(build.sourcesdirectory)\extractedMachineMsi + wix msi decompile $(InstallerUserRoot)\$(InstallerUserBasename).msi -x $(build.sourcesdirectory)\extractedUserMsi + dir $(build.sourcesdirectory)\extractedMachineMsi + dir $(build.sourcesdirectory)\extractedUserMsi + displayName: "WiX5: Extract and verify MSIs" # Check if deps.json files don't reference different dll versions. - pwsh: |- - & '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\extractedMsi\File' - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Audit deps.json in MSI extracted files + & '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\extractedMachineMsi\File' + & '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\extractedUserMsi\File' + displayName: Audit deps.json in MSI extracted files - ${{ if eq(parameters.codeSign, true) }}: - pwsh: |- - & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMsi\File' - & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMsi\Binary' - git clean -xfd ./extractedMsi - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Verify all binaries are signed and versioned + & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMachineMsi\File' + & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMachineMsi\Binary' + & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedUserMsi\File' + & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedUserMsi\Binary' + git clean -xfd ./extractedMachineMsi ./extractedUserMsi + displayName: Verify all binaries are signed and versioned - - template: steps-esrp-signing.yml + - template: steps-esrp-sign-files-authenticode.yml parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign VNext MSI + displayName: Sign VNext MSIs signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/$(InstallerFolder)/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' + folder: 'installer' + pattern: '**/PowerToys*Setup-*.msi' #### END MSI + #### BOOTSTRAP BUILDING AND SIGNING + # We pass BuildProjectReferences=false so that it does not recompile the DLLs we just built. + # We only pass -restore on the first one because the second run should already have all + # of the dependencies. - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build VNext Bootstrapper + displayName: 💻 Build VNext Bootstrapper inputs: solution: "**/installer/PowerToysSetup.sln" vsVersion: 17.0 msbuildArgs: >- -restore /t:PowerToysBootstrapperVNext - /p:PerUser=${{parameters.buildUserInstaller}};CIBuild=true - /p:InstallerSuffix=${{ parameters.installerSuffix }} - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-bootstrapper.binlog - -restore -graph + /p:PerUser=false;BuildProjectReferences=false;CIBuild=true + /bl:$(LogOutputDirectory)\installer-machine-bootstrapper.binlog ${{ parameters.additionalBuildOptions }} platform: $(BuildPlatform) configuration: $(BuildConfiguration) - clean: false # don't undo our hard work above by deleting the MSI + clean: false # don't undo our hard work above by deleting the MSI nor SilentFilesInUseBAFunction + msbuildArchitecture: x64 + maximumCpuCount: true + + - task: VSBuild@1 + displayName: 👤 Build VNext Bootstrapper + inputs: + solution: "**/installer/PowerToysSetup.sln" + vsVersion: 17.0 + msbuildArgs: >- + /t:PowerToysBootstrapperVNext + /p:PerUser=true;BuildProjectReferences=false;CIBuild=true + /bl:$(LogOutputDirectory)\installer-user-bootstrapper.binlog + ${{ parameters.additionalBuildOptions }} + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + clean: false # don't undo our hard work above by deleting the MSI nor SilentFilesInUseBAFunction msbuildArchitecture: x64 maximumCpuCount: true # The entirety of bundle unpacking/re-packing is unnecessary if we are not code signing it. - ${{ if eq(parameters.codeSign, true) }}: - script: |- - wix burn detach installer\$(InstallerFolder)\$(InstallerRelativePath)\$(InstallerBasename).exe -engine installer\engine.exe - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} WiX5: Extract Engine from Bundle" + wix burn detach $(InstallerMachineRoot)\$(InstallerMachineBasename).exe -engine installer\machine-engine.exe + wix burn detach $(InstallerUserRoot)\$(InstallerUserBasename).exe -engine installer\user-engine.exe + displayName: "WiX5: Extract Engines from Bundles" - - template: steps-esrp-signing.yml + - template: steps-esrp-sign-files-authenticode.yml parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign WiX Engine + displayName: Sign WiX Engines signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: "installer" - Pattern: engine.exe - signConfigType: inlineSignParams - inlineOperation: | - [ - { - "KeyCode": "CP-230012", - "OperationCode": "SigntoolSign", - "Parameters": { - "OpusName": "Microsoft", - "OpusInfo": "http://www.microsoft.com", - "FileDigest": "/fd \"SHA256\"", - "PageHash": "/NPH", - "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" - }, - "ToolName": "sign", - "ToolVersion": "1.0" - }, - { - "KeyCode": "CP-230012", - "OperationCode": "SigntoolVerify", - "Parameters": {}, - "ToolName": "sign", - "ToolVersion": "1.0" - } - ] + folder: "installer" + pattern: '*-engine.exe' - script: |- - wix burn reattach installer\$(InstallerFolder)\$(InstallerRelativePath)\$(InstallerBasename).exe -engine installer\engine.exe -o installer\$(InstallerFolder)\$(InstallerRelativePath)\$(InstallerBasename).exe - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} WiX5: Reattach Engine to Bundle" + wix burn reattach $(InstallerMachineRoot)\$(InstallerMachineBasename).exe -engine installer\machine-engine.exe -o $(InstallerMachineRoot)\$(InstallerMachineBasename).exe + wix burn reattach $(InstallerUserRoot)\$(InstallerUserBasename).exe -engine installer\user-engine.exe -o $(InstallerUserRoot)\$(InstallerUserBasename).exe + displayName: "WiX5: Reattach Engines to Bundles" - - template: steps-esrp-signing.yml + - pwsh: |- + & wix burn extract -oba installer\ba\m "$(InstallerMachineRoot)\$(InstallerMachineBasename).exe" + & wix burn extract -oba installer\ba\u "$(InstallerUserRoot)\$(InstallerUserBasename).exe" + Get-ChildItem installer\ba -Recurse -Include *.exe,*.dll | Get-AuthenticodeSignature | ForEach-Object { + If ($_.Status -Ne "Valid") { + Write-Error $_.StatusMessage + } Else { + Write-Host $_.StatusMessage + } + } + & git clean -fdx installer\ba + displayName: "WiX5: Verify Bootstrapper content is signed" + + - template: steps-esrp-sign-files-authenticode.yml parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign Final Bootstrapper + displayName: Sign Final Bootstrappers signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/$(InstallerFolder)/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' + folder: 'installer' + pattern: '**/PowerToys*Setup-*.exe' #### END BOOTSTRAP ## END INSTALLER diff --git a/.pipelines/v2/templates/steps-build-installer.yml b/.pipelines/v2/templates/steps-build-installer.yml deleted file mode 100644 index 8c3c89dbc0..0000000000 --- a/.pipelines/v2/templates/steps-build-installer.yml +++ /dev/null @@ -1,208 +0,0 @@ -parameters: - - name: versionNumber - type: string - default: "0.0.1" - - name: buildUserInstaller - type: boolean - default: false - - name: codeSign - type: boolean - default: false - - name: signingIdentity - type: object - default: {} - - name: additionalBuildOptions - type: string - default: '' - -steps: - - pwsh: |- - & git clean -xfd -e *exe -- .\installer\ - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Clean installer to reduce cross-contamination - - - pwsh: |- - $IsPerUser = $${{ parameters.buildUserInstaller }} - $InstallerBuildSlug = "MachineSetup" - $InstallerBasename = "PowerToysSetup" - If($IsPerUser) { - $InstallerBuildSlug = "UserSetup" - $InstallerBasename = "PowerToysUserSetup" - } - $InstallerBasename += "-${{ parameters.versionNumber }}-$(BuildPlatform)" - Write-Host "##vso[task.setvariable variable=InstallerBuildSlug]$InstallerBuildSlug" - Write-Host "##vso[task.setvariable variable=InstallerRelativePath]$(BuildPlatform)\$(BuildConfiguration)\$InstallerBuildSlug" - Write-Host "##vso[task.setvariable variable=InstallerBasename]$InstallerBasename" - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Prepare Installer variables - - # This dll needs to be built and signed before building the MSI. - - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build PowerToysSetupCustomActions - inputs: - solution: "**/installer/PowerToysSetup.sln" - vsVersion: 17.0 - msbuildArgs: >- - /t:PowerToysSetupCustomActions - /p:RunBuildEvents=true;PerUser=${{parameters.buildUserInstaller}};RestorePackagesConfig=true;CIBuild=true - -restore -graph - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-actions.binlog - ${{ parameters.additionalBuildOptions }} - platform: $(BuildPlatform) - configuration: $(BuildConfiguration) - clean: true - msbuildArchitecture: x64 - maximumCpuCount: true - - - ${{ if eq(parameters.codeSign, true) }}: - - template: steps-esrp-signing.yml - parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign PowerToysSetupCustomActions - signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/PowerToysSetupCustomActions/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' - - ## INSTALLER START - #### MSI BUILDING AND SIGNING - - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build MSI - inputs: - solution: "**/installer/PowerToysSetup.sln" - vsVersion: 17.0 - msbuildArgs: >- - -restore - /t:PowerToysInstaller - /p:RunBuildEvents=false;PerUser=${{parameters.buildUserInstaller}};BuildProjectReferences=false;CIBuild=true - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-msi.binlog - ${{ parameters.additionalBuildOptions }} - platform: $(BuildPlatform) - configuration: $(BuildConfiguration) - clean: false # don't undo our hard work above by deleting the CustomActions dll - msbuildArchitecture: x64 - maximumCpuCount: true - - - script: |- - "C:\Program Files (x86)\WiX Toolset v3.14\bin\dark.exe" -x $(build.sourcesdirectory)\extractedMsi installer\PowerToysSetup\$(InstallerRelativePath)\$(InstallerBasename).msi - dir $(build.sourcesdirectory)\extractedMsi - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Extract and verify MSI" - - # Extract CmdPal msix package to check if its content is signed - - pwsh: |- - Write-Host "Extracting CmdPal MSIX package" - - # Define the directory to search - $searchDir = "extractedMsi\File" - - # Define the regex pattern for MSIX files - $pattern = '^Microsoft.CmdPal.UI.*\.msix$' - - # Get all files in the directory and subdirectories - $msixFile = Get-ChildItem -Path $searchDir -Recurse -File | Where-Object { - $_.Name -match $pattern - } - - Write-Host "MSIX file found: " $msixFile - - $destinationDir = "$(build.sourcesdirectory)\extractedMsi\File\extractedCmdPalMsix" - - Expand-Archive -Path $msixFile -DestinationPath $destinationDir - Get-ChildItem -Path $destinationDir -Recurse -File - - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Extract CmdPal MSIX package - - # Check if deps.json files don't reference different dll versions. - - pwsh: |- - & '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\extractedMsi\File' - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Audit deps.json in MSI extracted files - - - ${{ if eq(parameters.codeSign, true) }}: - - pwsh: |- - & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMsi\File' - & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMsi\Binary' - git clean -xfd ./extractedMsi - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Verify all binaries are signed and versioned - - - template: steps-esrp-signing.yml - parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign MSI - signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/PowerToysSetup/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' - - #### END MSI - #### BOOTSTRAP BUILDING AND SIGNING - - - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build Bootstrapper - inputs: - solution: "**/installer/PowerToysSetup.sln" - vsVersion: 17.0 - msbuildArgs: >- - /t:PowerToysBootstrapper - /p:PerUser=${{parameters.buildUserInstaller}};CIBuild=true - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-bootstrapper.binlog - -restore -graph - ${{ parameters.additionalBuildOptions }} - platform: $(BuildPlatform) - configuration: $(BuildConfiguration) - clean: false # don't undo our hard work above by deleting the MSI - msbuildArchitecture: x64 - maximumCpuCount: true - - # The entirety of bundle unpacking/re-packing is unnecessary if we are not code signing it. - - ${{ if eq(parameters.codeSign, true) }}: - - script: |- - "C:\Program Files (x86)\WiX Toolset v3.14\bin\insignia.exe" -ib installer\PowerToysSetup\$(InstallerRelativePath)\$(InstallerBasename).exe -o installer\engine.exe - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Insignia: Extract Engine from Bundle" - - - template: steps-esrp-signing.yml - parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign WiX Engine - signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: "installer" - Pattern: engine.exe - signConfigType: inlineSignParams - inlineOperation: | - [ - { - "KeyCode": "CP-230012", - "OperationCode": "SigntoolSign", - "Parameters": { - "OpusName": "Microsoft", - "OpusInfo": "http://www.microsoft.com", - "FileDigest": "/fd \"SHA256\"", - "PageHash": "/NPH", - "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" - }, - "ToolName": "sign", - "ToolVersion": "1.0" - }, - { - "KeyCode": "CP-230012", - "OperationCode": "SigntoolVerify", - "Parameters": {}, - "ToolName": "sign", - "ToolVersion": "1.0" - } - ] - - - script: |- - "C:\Program Files (x86)\WiX Toolset v3.14\bin\insignia.exe" -ab installer\engine.exe installer\PowerToysSetup\$(InstallerRelativePath)\$(InstallerBasename).exe -o installer\PowerToysSetup\$(InstallerRelativePath)\$(InstallerBasename).exe - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Insignia: Merge Engine into Bundle" - - - template: steps-esrp-signing.yml - parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign Final Bootstrapper - signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/PowerToysSetup/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' - #### END BOOTSTRAP - ## END INSTALLER diff --git a/.pipelines/v2/templates/steps-esrp-sign-files-authenticode.yml b/.pipelines/v2/templates/steps-esrp-sign-files-authenticode.yml new file mode 100644 index 0000000000..5b9bbd2fce --- /dev/null +++ b/.pipelines/v2/templates/steps-esrp-sign-files-authenticode.yml @@ -0,0 +1,45 @@ +parameters: + - name: displayName + type: string + default: Sign Specific Files + - name: folder + type: string + - name: pattern + type: string + - name: signingIdentity + type: object + default: {} + +steps: + - template: steps-esrp-signing.yml + parameters: + displayName: ${{ parameters.displayName }} + signingIdentity: ${{ parameters.signingIdentity }} + inputs: + FolderPath: ${{ parameters.folder }} + Pattern: ${{ parameters.pattern }} + UseMinimatch: true + signConfigType: inlineSignParams + inlineOperation: |- + [ + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolSign", + "Parameters": { + "OpusName": "Microsoft", + "OpusInfo": "http://www.microsoft.com", + "FileDigest": "/fd \"SHA256\"", + "PageHash": "/NPH", + "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + }, + "ToolName": "sign", + "ToolVersion": "1.0" + }, + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolVerify", + "Parameters": {}, + "ToolName": "sign", + "ToolVersion": "1.0" + } + ] diff --git a/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml b/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml index 44f8c4b6dc..58f2fe6c47 100644 --- a/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml +++ b/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml @@ -8,8 +8,8 @@ steps: displayName: 'Download Localization Files -- PowerToys 37400' inputs: teamId: 37400 - TDBuildServiceConnection: $(TouchdownServiceConnection) - authType: SubjectNameIssuer + FederatedIdentityTDBuildServiceConnection: $(TouchdownServiceConnection) + authType: FederatedIdentityTDBuild resourceFilePath: | **\Resources.resx **\Resource.resx diff --git a/.pipelines/verifyCommonProps.ps1 b/.pipelines/verifyCommonProps.ps1 index 028578234c..7ed52f6bf1 100644 --- a/.pipelines/verifyCommonProps.ps1 +++ b/.pipelines/verifyCommonProps.ps1 @@ -39,6 +39,14 @@ foreach ($csprojFile in $csprojFilesArray) { if ($csprojFile -like '*TemplateCmdPalExtension.csproj') { continue } + + # The CmdPal.Core projects use a common shared props file, so skip them + if ($csprojFile -like '*Microsoft.CmdPal.Core.*.csproj') { + continue + } + if ($csprojFile -like '*Microsoft.CmdPal.Ext.Shell.csproj') { + continue + } $importExists = Test-ImportSharedCsWinRTProps -filePath $csprojFile if (!$importExists) { diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..940ff302de --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,54 @@ +{ + "version": "0.2.0", + "inputs": [ + { + "id": "arch", + "type": "pickString", + "description": "Select target architecture", + "options": ["x64", "arm64"], + "default": "x64" + } + ], + "configurations": [ + { + "name": "Run native executable (no build)", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\PowerToys.exe", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "console": "integratedTerminal" + }, + { + "name": "C/C++ Attach to PowerToys Process (native)", + "type": "cppvsdbg", + "request": "attach", + "processId": "${command:pickProcess}", + "symbolSearchPath": "${workspaceFolder}\\${input:arch}\\Debug;${workspaceFolder}\\Debug;${workspaceFolder}\\symbols" + }, + { + "name": "Run managed code (managed, no build, ARCH configurable)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\WinUI3Apps\\PowerToys.Settings.exe", + "args": [], + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": "Run AdvancedPaste (managed, no build, ARCH configurable)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\WinUI3Apps\\PowerToys.AdvancedPaste.exe", + "args": [], + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole", + "stopAtEntry": false + }, + ] +} \ No newline at end of file diff --git a/Cpp.Build.props b/Cpp.Build.props index 5a4538f940..f146a4d770 100644 --- a/Cpp.Build.props +++ b/Cpp.Build.props @@ -26,6 +26,7 @@ true $(MsbuildThisFileDirectory)\CppRuleSet.ruleset + $(MSBuildThisFileDirectory)deps;$(MSBuildThisFileDirectory)packages;$(CAExcludePath) @@ -34,7 +35,7 @@ arm64 false true - $(MSBuildThisFileFullPath)\..\deps\;$(MSBuildThisFileFullPath)\..\packages\;$(ExternalIncludePath) + $(MSBuildThisFileDirectory)deps;$(MSBuildThisFileDirectory)packages;$(ExternalIncludePath) Guard diff --git a/DATA_AND_PRIVACY.md b/DATA_AND_PRIVACY.md index 56a2eb9eee..0ad4bda9c9 100644 --- a/DATA_AND_PRIVACY.md +++ b/DATA_AND_PRIVACY.md @@ -147,6 +147,18 @@ _If you want to find diagnostic data events in the source code, these two links Microsoft.PowerToys.AdvancedPasteSemanticKernelFormatEvent Triggered when Advanced Paste leverages the Semantic Kernel. + + Microsoft.PowerToys.AdvancedPasteSemanticKernelErrorEvent + Occurs when the Semantic Kernel workflow encounters an error. + + + Microsoft.PowerToys.AdvancedPasteEndpointUsageEvent + Logs the AI provider, model, and processing duration for each endpoint call. + + + Microsoft.PowerToys.AdvancedPasteCustomActionErrorEvent + Records provider, model, and status details when a custom action fails. + ### Always on Top diff --git a/Directory.Build.props b/Directory.Build.props index 4184a8f2a3..e7b415cbca 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -30,7 +30,6 @@ <_PropertySheetDisplayName>PowerToys.Root.Props $(MsbuildThisFileDirectory)\Cpp.Build.props - all diff --git a/Directory.Build.targets b/Directory.Build.targets index cba7762d5f..6da66bc8a8 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -3,4 +3,9 @@ + + + + $(WindowsSdkDir)bin\x64\mt.exe + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 649c927a64..75b2399c8a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,14 +1,15 @@ - + true + true + - @@ -21,11 +22,11 @@ - - + + @@ -34,22 +35,33 @@ - + - + + - - - - - - + + + + + + + + + + + + + + + + - + - + - + - - + + + + + - + @@ -79,33 +94,34 @@ - + - - - + + + - + - - + + + - + - - - - + + + + - + @@ -122,4 +138,4 @@ - + \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md index bedc11379d..6ca3cbfceb 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1495,10 +1495,10 @@ SOFTWARE. - AdaptiveCards.Rendering.WinUI3 - AdaptiveCards.Templating - Appium.WebDriver -- Azure.AI.OpenAI - CoenM.ImageSharp.ImageHash - CommunityToolkit.Common - CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock +- CommunityToolkit.Labs.WinUI.Controls.OpacityMaskView - CommunityToolkit.Mvvm - CommunityToolkit.WinUI.Animations - CommunityToolkit.WinUI.Collections @@ -1509,7 +1509,6 @@ SOFTWARE. - CommunityToolkit.WinUI.Converters - CommunityToolkit.WinUI.Extensions - CommunityToolkit.WinUI.UI.Controls.DataGrid -- CommunityToolkit.WinUI.UI.Controls.Markdown - ControlzEx - HelixToolkit - HelixToolkit.Core.Wpf @@ -1522,6 +1521,7 @@ SOFTWARE. - ModernWpfUI - Moq - MSTest +- NJsonSchema - NLog - NLog.Extensions.Logging - NLog.Schema diff --git a/PowerToys.sln b/PowerToys.sln index c2f96095ac..e34779c5bb 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -5,11 +5,13 @@ MinimumVisualStudioVersion = 10.0.40219.1 Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "runner", "src\runner\runner.vcxproj", "{9412D5C6-2CF2-4FC2-A601-B55508EA9B27}" ProjectSection(ProjectDependencies) = postProject {031AC72E-FA28-4AB7-B690-6F7B9C28AA73} = {031AC72E-FA28-4AB7-B690-6F7B9C28AA73} + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC} = {08E71C67-6A7E-4CA1-B04E-2FB336410BAC} {0B43679E-EDFA-4DA0-AD30-F4628B308B1B} = {0B43679E-EDFA-4DA0-AD30-F4628B308B1B} {0B593A6C-4143-4337-860E-DB5710FB87DB} = {0B593A6C-4143-4337-860E-DB5710FB87DB} {17DA04DF-E393-4397-9CF0-84DABE11032E} = {17DA04DF-E393-4397-9CF0-84DABE11032E} {217DF501-135C-4E38-BFC8-99D4821032EA} = {217DF501-135C-4E38-BFC8-99D4821032EA} {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34} = {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34} + {38177D56-6AD1-4ADF-88C9-2843A7932166} = {38177D56-6AD1-4ADF-88C9-2843A7932166} {48804216-2A0E-4168-A6D8-9CD068D14227} = {48804216-2A0E-4168-A6D8-9CD068D14227} {51920F1F-C28C-4ADF-8660-4238766796C2} = {51920F1F-C28C-4ADF-8660-4238766796C2} {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} = {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} @@ -24,6 +26,7 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "runner", "src\runner\runner {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D} = {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D} {D940E07F-532C-4FF3-883F-790DA014F19A} = {D940E07F-532C-4FF3-883F-790DA014F19A} {DA425894-6E13-404F-8DCB-78584EC0557A} = {DA425894-6E13-404F-8DCB-78584EC0557A} + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D} = {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D} {E364F67B-BB12-4E91-B639-355866EBCD8B} = {E364F67B-BB12-4E91-B639-355866EBCD8B} {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} = {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} EndProjectSection @@ -48,6 +51,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{1AFB64 EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Common.Lib.UnitTests", "src\common\UnitTests-CommonLib\UnitTests-CommonLib.vcxproj", "{1A066C63-64B3-45F8-92FE-664E1CCE8077}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PackageIdentity", "src\PackageIdentity\PackageIdentity.vcxproj", "{E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FancyZonesEditor", "src\modules\fancyzones\editor\FancyZonesEditor\FancyZonesEditor.csproj", "{5CCC8468-DEC8-4D36-99D4-5C891BEBD481}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "powerrename", "powerrename", "{89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3}" @@ -638,7 +643,7 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Microsoft.CommandPalette.Ex EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CommandPalette.Extensions.Toolkit", "src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj", "{CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Common", "src\modules\cmdpal\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj", "{14E62033-58D0-4A7D-8990-52F50A08BBBD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Core.Common", "src\modules\cmdpal\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj", "{14E62033-58D0-4A7D-8990-52F50A08BBBD}" EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Microsoft.Terminal.UI", "src\modules\cmdpal\Microsoft.Terminal.UI\Microsoft.Terminal.UI.vcxproj", "{6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}" EndProject @@ -728,7 +733,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerRename.UITests", "src\ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Core.ViewModels", "src\modules\cmdpal\Microsoft.CmdPal.Core.ViewModels\Microsoft.CmdPal.Core.ViewModels.csproj", "{24133F7F-C1D1-DE04-EFA8-F5D5467FE027}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Core.ViewModels", "src\modules\cmdpal\Core\Microsoft.CmdPal.Core.ViewModels\Microsoft.CmdPal.Core.ViewModels.csproj", "{24133F7F-C1D1-DE04-EFA8-F5D5467FE027}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0E556541-6A45-42CB-AE49-EE5A9BE05E7C}" EndProject @@ -793,10 +798,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Window EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.UnitTestBase", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj", "{00D8659C-2068-40B6-8B86-759CD6284BBB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LightSwitch", "LightSwitch", "{5B201255-53C8-490B-A34F-01F05D48A477}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "LightSwitchModuleInterface", "src\modules\LightSwitch\LightSwitchModuleInterface\LightSwitchModuleInterface.vcxproj", "{38177D56-6AD1-4ADF-88C9-2843A7932166}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "LightSwitchService", "src\modules\LightSwitch\LightSwitchService\LightSwitchService.vcxproj", "{08E71C67-6A7E-4CA1-B04E-2FB336410BAC}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{E11826E1-76DF-42AC-985C-164CC2EE57A1}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScreenRuler.UITests", "src\modules\MeasureTool\Tests\ScreenRuler.UITests\ScreenRuler.UITests.csproj", "{66C069F8-C548-4CA6-8CDE-239104D68E88}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "v3", "v3", "{9605B84E-FAC4-477B-B9EC-0753177EE6A8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerToys.DSC", "src\dsc\v3\PowerToys.DSC\PowerToys.DSC.csproj", "{94CDC147-6137-45E9-AEDE-17FF809607C0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerToys.DSC.UnitTests", "src\dsc\v3\PowerToys.DSC.UnitTests\PowerToys.DSC.UnitTests.csproj", "{A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Apps.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Apps.UnitTests\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj", "{E816D7B1-4688-4ECB-97CC-3D8E798F3830}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Bookmarks.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj", "{E816D7B3-4688-4ECB-97CC-3D8E798F3832}" @@ -805,6 +822,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSea EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CursorWrap", "src\modules\MouseUtils\CursorWrap\CursorWrap.vcxproj", "{48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3DCCD936-D085-4869-A1DE-CA6A64152C94}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\modules\LightSwitch\Tests\LightSwitch.UITests\LightSwitch.UITests.csproj", "{F5333ED7-06D8-4AB3-953A-36D63F08CB6F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", "{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanguageModelProvider", "src\common\LanguageModelProvider\LanguageModelProvider.csproj", "{45354F4F-1414-45CE-B600-51CD1209FD19}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.UI.ViewModels.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.UI.ViewModels.UnitTests\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj", "{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -845,6 +874,14 @@ Global {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|ARM64.Build.0 = Release|ARM64 {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|x64.ActiveCfg = Release|x64 {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|x64.Build.0 = Release|x64 + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Debug|ARM64.Build.0 = Debug|ARM64 + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Debug|x64.ActiveCfg = Debug|x64 + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Debug|x64.Build.0 = Debug|x64 + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Release|ARM64.ActiveCfg = Release|ARM64 + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Release|ARM64.Build.0 = Release|ARM64 + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Release|x64.ActiveCfg = Release|x64 + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Release|x64.Build.0 = Release|x64 {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|ARM64.ActiveCfg = Debug|ARM64 {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|ARM64.Build.0 = Debug|ARM64 {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|x64.ActiveCfg = Debug|x64 @@ -2883,6 +2920,22 @@ Global {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|ARM64.Build.0 = Release|ARM64 {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|x64.ActiveCfg = Release|x64 {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|x64.Build.0 = Release|x64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Debug|ARM64.Build.0 = Debug|ARM64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Debug|x64.ActiveCfg = Debug|x64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Debug|x64.Build.0 = Debug|x64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Release|ARM64.ActiveCfg = Release|ARM64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Release|ARM64.Build.0 = Release|ARM64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Release|x64.ActiveCfg = Release|x64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Release|x64.Build.0 = Release|x64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Debug|ARM64.Build.0 = Debug|ARM64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Debug|x64.ActiveCfg = Debug|x64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Debug|x64.Build.0 = Debug|x64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Release|ARM64.ActiveCfg = Release|ARM64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Release|ARM64.Build.0 = Release|ARM64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Release|x64.ActiveCfg = Release|x64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Release|x64.Build.0 = Release|x64 {66C069F8-C548-4CA6-8CDE-239104D68E88}.Debug|ARM64.ActiveCfg = Debug|ARM64 {66C069F8-C548-4CA6-8CDE-239104D68E88}.Debug|ARM64.Build.0 = Debug|ARM64 {66C069F8-C548-4CA6-8CDE-239104D68E88}.Debug|x64.ActiveCfg = Debug|x64 @@ -2891,6 +2944,22 @@ Global {66C069F8-C548-4CA6-8CDE-239104D68E88}.Release|ARM64.Build.0 = Release|ARM64 {66C069F8-C548-4CA6-8CDE-239104D68E88}.Release|x64.ActiveCfg = Release|x64 {66C069F8-C548-4CA6-8CDE-239104D68E88}.Release|x64.Build.0 = Release|x64 + {94CDC147-6137-45E9-AEDE-17FF809607C0}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {94CDC147-6137-45E9-AEDE-17FF809607C0}.Debug|ARM64.Build.0 = Debug|ARM64 + {94CDC147-6137-45E9-AEDE-17FF809607C0}.Debug|x64.ActiveCfg = Debug|x64 + {94CDC147-6137-45E9-AEDE-17FF809607C0}.Debug|x64.Build.0 = Debug|x64 + {94CDC147-6137-45E9-AEDE-17FF809607C0}.Release|ARM64.ActiveCfg = Release|ARM64 + {94CDC147-6137-45E9-AEDE-17FF809607C0}.Release|ARM64.Build.0 = Release|ARM64 + {94CDC147-6137-45E9-AEDE-17FF809607C0}.Release|x64.ActiveCfg = Release|x64 + {94CDC147-6137-45E9-AEDE-17FF809607C0}.Release|x64.Build.0 = Release|x64 + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}.Debug|ARM64.Build.0 = Debug|ARM64 + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}.Debug|x64.ActiveCfg = Debug|x64 + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}.Debug|x64.Build.0 = Debug|x64 + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}.Release|ARM64.ActiveCfg = Release|ARM64 + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}.Release|ARM64.Build.0 = Release|ARM64 + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}.Release|x64.ActiveCfg = Release|x64 + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78}.Release|x64.Build.0 = Release|x64 {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Debug|ARM64.ActiveCfg = Debug|ARM64 {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Debug|ARM64.Build.0 = Debug|ARM64 {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Debug|x64.ActiveCfg = Debug|x64 @@ -2923,6 +2992,50 @@ Global {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|ARM64.Build.0 = Debug|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|x64.ActiveCfg = Debug|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|x64.Build.0 = Debug|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|ARM64.ActiveCfg = Release|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|ARM64.Build.0 = Release|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|x64.ActiveCfg = Release|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|x64.Build.0 = Release|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Build.0 = Debug|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Deploy.0 = Debug|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|x64.ActiveCfg = Debug|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|x64.Build.0 = Debug|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|x64.Deploy.0 = Debug|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|ARM64.ActiveCfg = Release|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|ARM64.Build.0 = Release|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|ARM64.Deploy.0 = Release|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.ActiveCfg = Release|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.Build.0 = Release|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.Deploy.0 = Release|x64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|ARM64.Build.0 = Debug|ARM64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|x64.ActiveCfg = Debug|x64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|x64.Build.0 = Debug|x64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.ActiveCfg = Release|ARM64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.Build.0 = Release|ARM64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.Build.0 = Release|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|ARM64.Build.0 = Debug|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|x64.ActiveCfg = Debug|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|x64.Build.0 = Debug|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|ARM64.ActiveCfg = Release|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|ARM64.Build.0 = Release|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|x64.ActiveCfg = Release|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|x64.Build.0 = Release|x64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.Build.0 = Debug|ARM64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.ActiveCfg = Debug|x64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.Build.0 = Debug|x64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.ActiveCfg = Release|ARM64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.Build.0 = Release|ARM64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.ActiveCfg = Release|x64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -2932,7 +3045,6 @@ Global {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9} = {264B412F-DB8B-4CF8-A74B-96998B183045} - {1AFB6476-670D-4E80-A464-657E01DFF482} = {557C4636-D7E1-4838-A504-7D19B725EE95} {1A066C63-64B3-45F8-92FE-664E1CCE8077} = {1AFB6476-670D-4E80-A464-657E01DFF482} {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} @@ -3161,7 +3273,7 @@ Global {F3D09629-59A2-4924-A4B9-D6BFAA2C1B49} = {3846508C-77EB-4034-A702-F8BB263C4F79} {305DD37E-C85D-4B08-AAFE-7381FA890463} = {F3D09629-59A2-4924-A4B9-D6BFAA2C1B49} {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24} = {F3D09629-59A2-4924-A4B9-D6BFAA2C1B49} - {14E62033-58D0-4A7D-8990-52F50A08BBBD} = {7520A2FE-00A2-49B8-83ED-DB216E874C04} + {14E62033-58D0-4A7D-8990-52F50A08BBBD} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F} = {7520A2FE-00A2-49B8-83ED-DB216E874C04} {071E18A4-A530-46B8-AB7D-B862EE55E24E} = {3846508C-77EB-4034-A702-F8BB263C4F79} {C846F7A7-792A-47D9-B0CB-417C900EE03D} = {071E18A4-A530-46B8-AB7D-B862EE55E24E} @@ -3237,12 +3349,24 @@ Global {E816D7AF-4688-4ECB-97CC-3D8E798F3828} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B0-4688-4ECB-97CC-3D8E798F3829} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {00D8659C-2068-40B6-8B86-759CD6284BBB} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {5B201255-53C8-490B-A34F-01F05D48A477} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} + {38177D56-6AD1-4ADF-88C9-2843A7932166} = {5B201255-53C8-490B-A34F-01F05D48A477} + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC} = {5B201255-53C8-490B-A34F-01F05D48A477} {E11826E1-76DF-42AC-985C-164CC2EE57A1} = {7AC943C9-52E8-44CF-9083-744D8049667B} {66C069F8-C548-4CA6-8CDE-239104D68E88} = {E11826E1-76DF-42AC-985C-164CC2EE57A1} + {9605B84E-FAC4-477B-B9EC-0753177EE6A8} = {557C4636-D7E1-4838-A504-7D19B725EE95} + {94CDC147-6137-45E9-AEDE-17FF809607C0} = {9605B84E-FAC4-477B-B9EC-0753177EE6A8} + {A24BF1AF-79AA-4896-BAE3-CCBBE0380A78} = {9605B84E-FAC4-477B-B9EC-0753177EE6A8} {E816D7B1-4688-4ECB-97CC-3D8E798F3830} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5} = {322566EF-20DC-43A6-B9F8-616AF942579A} + {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {45354F4F-1414-45CE-B600-51CD1209FD19} = {1AFB6476-670D-4E80-A464-657E01DFF482} + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/README.md b/README.md index cf2dd7beba..624d95501b 100644 --- a/README.md +++ b/README.md @@ -1,259 +1,256 @@ -# Microsoft PowerToys +

+ + + + +

+

+ Microsoft PowerToys +

+

+ Microsoft PowerToys is a collection of utilities that help you customize Windows and streamline everyday tasks. +

+

+ Installation + · + Documentation + · + Blog + · + Release notes +

+

-![Hero image for Microsoft PowerToys](doc/images/overview/PT_hero_image.png) +## 🔨 Utilities -[How to use PowerToys][usingPowerToys-docs-link] | [Downloads & Release notes][github-release-link] | [Contributing to PowerToys](#contributing) | [What's Happening](#whats-happening) | [Roadmap](#powertoys-roadmap) +PowerToys includes over 25 utilities to help you customize and optimize your Windows experience: -## About +| | | | +|---|---|---| +| [Advanced Paste icon Advanced Paste](https://aka.ms/PowerToysOverview_AdvancedPaste) | [Always on Top icon Always on Top](https://aka.ms/PowerToysOverview_AoT) | [Awake icon Awake](https://aka.ms/PowerToysOverview_Awake) | +| [Color Picker icon Color Picker](https://aka.ms/PowerToysOverview_ColorPicker) | [Command Not Found icon Command Not Found](https://aka.ms/PowerToysOverview_CmdNotFound) | [Command Palette icon Command Palette](https://aka.ms/PowerToysOverview_CmdPal) | +| [Crop and Lock icon Crop And Lock](https://aka.ms/PowerToysOverview_CropAndLock) | [Environment Variables icon Environment Variables](https://aka.ms/PowerToysOverview_EnvironmentVariables) | [FancyZones icon FancyZones](https://aka.ms/PowerToysOverview_FancyZones) | +| [File Explorer Add-ons icon File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [File Locksmith icon File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [Hosts File Editor icon Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) | +| [Image Resizer icon Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [Keyboard Manager icon Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) | [Light Switch icon Light Switch](https://aka.ms/PowerToysOverview_LightSwitch) | +| [Mouse Utilities icon Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [Mouse Without Borders icon Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) | [New+ icon New+](https://aka.ms/PowerToysOverview_NewPlus) | +| [Peek icon Peek](https://aka.ms/PowerToysOverview_Peek) | [PowerRename icon PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [PowerToys Run icon PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) | +| [Quick Accent icon Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) | [Registry Preview icon Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [Screen Ruler icon Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) | +| [Shortcut Guide icon Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) | [Text Extractor icon Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [Workspaces icon Workspaces](https://aka.ms/PowerToysOverview_Workspaces) | +| [ZoomIt icon ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) | | | -Microsoft PowerToys is a set of utilities for power users to tune and streamline their Windows experience for greater productivity. For more info on [PowerToys overviews and how to use the utilities][usingPowerToys-docs-link], or any other tools and resources for [Windows development environments](https://learn.microsoft.com/windows/dev-environment/overview), head over to [learn.microsoft.com][usingPowerToys-docs-link]! -| | Current utilities: | | -|--------------|--------------------|--------------| -| [Advanced Paste](https://aka.ms/PowerToysOverview_AdvancedPaste) | [Always on Top](https://aka.ms/PowerToysOverview_AoT) | [PowerToys Awake](https://aka.ms/PowerToysOverview_Awake) | -| [Color Picker](https://aka.ms/PowerToysOverview_ColorPicker) | [Command Not Found](https://aka.ms/PowerToysOverview_CmdNotFound) | [Command Palette](https://aka.ms/PowerToysOverview_CmdPal) | -| [Crop And Lock](https://aka.ms/PowerToysOverview_CropAndLock) | [Environment Variables](https://aka.ms/PowerToysOverview_EnvironmentVariables) | [FancyZones](https://aka.ms/PowerToysOverview_FancyZones) | -| [File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) | -| [Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) | [Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | -| [Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) | [New+](https://aka.ms/PowerToysOverview_NewPlus) | [Paste as Plain Text](https://aka.ms/PowerToysOverview_PastePlain) | -| [Peek](https://aka.ms/PowerToysOverview_Peek) | [PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) | -| [Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) | [Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) | -| [Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) | [Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [Workspaces](https://aka.ms/PowerToysOverview_Workspaces) | -| [ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) | +## 📋 Installation -## Installing and running Microsoft PowerToys +For detailed installation instructions and system requirements, visit the [installation docs](https://learn.microsoft.com/windows/powertoys/install). -### Requirements - -- Windows 11 or Windows 10 version 2004 (code name 20H1 / build number 19041) or newer. -- x64 or ARM64 processor -- Our installer will install the following items: - - [Microsoft Edge WebView2 Runtime](https://go.microsoft.com/fwlink/p/?LinkId=2124703) bootstrapper. This will install the latest version. - -### Via GitHub with EXE [Recommended] - -Go to the [Microsoft PowerToys GitHub releases page][github-release-link] and click on `Assets` at the bottom to show the files available in the release. Please use the appropriate PowerToys installer that matches your machine's architecture and install scope. For most, it is `x64` and per-user. +But to get started quickly, choose one of the installation methods below: +

+
+Download .exe from GitHub +
+Go to the [PowerToys GitHub releases][github-release-link], click Assets to reveal the downloads, and choose the installer that matches your architecture and install scope. For most devices, that's the x64 per-user installer. -[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.95%22 -[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.94%22 -[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.94.0/PowerToysUserSetup-0.94.0-x64.exe -[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.94.0/PowerToysUserSetup-0.94.0-arm64.exe -[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.94.0/PowerToysSetup-0.94.0-x64.exe -[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.94.0/PowerToysSetup-0.94.0-arm64.exe +[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22 +[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.96%22 +[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysUserSetup-0.96.0-x64.exe +[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysUserSetup-0.96.0-arm64.exe +[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysSetup-0.96.0-x64.exe +[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysSetup-0.96.0-arm64.exe | Description | Filename | |----------------|----------| -| Per user - x64 | [PowerToysUserSetup-0.94.0-x64.exe][ptUserX64] | -| Per user - ARM64 | [PowerToysUserSetup-0.94.0-arm64.exe][ptUserArm64] | -| Machine wide - x64 | [PowerToysSetup-0.94.0-x64.exe][ptMachineX64] | -| Machine wide - ARM64 | [PowerToysSetup-0.94.0-arm64.exe][ptMachineArm64] | +| Per user - x64 | [PowerToysUserSetup-0.96.0-x64.exe][ptUserX64] | +| Per user - ARM64 | [PowerToysUserSetup-0.96.0-arm64.exe][ptUserArm64] | +| Machine wide - x64 | [PowerToysSetup-0.96.0-x64.exe][ptMachineX64] | +| Machine wide - ARM64 | [PowerToysSetup-0.96.0-arm64.exe][ptMachineArm64] | -This is our preferred method. +
-### Via Microsoft Store +
+Microsoft Store +
+You can easily install PowerToys from the Microsoft Store: +

+ + + + + +

+
-Install from the [Microsoft Store's PowerToys page][microsoft-store-link]. You must be using the [new Microsoft Store](https://blogs.windows.com/windowsExperience/2021/06/24/building-a-new-open-microsoft-store-on-windows-11/), which is available for both Windows 11 and Windows 10. - -### Via WinGet +
+WinGet +
Download PowerToys from [WinGet][winget-link]. Updating PowerToys via winget will respect the current PowerToys installation scope. To install PowerToys, run the following command from the command line / PowerShell: -#### User scope installer [default] +*User scope installer [default]* ```powershell winget install Microsoft.PowerToys -s winget ``` -#### Machine-wide scope installer - +*Machine-wide scope installer* ```powershell winget install --scope machine Microsoft.PowerToys -s winget ``` +
-### Other install methods - +
+Other methods +
There are [community driven install methods](./doc/unofficialInstallMethods.md) such as Chocolatey and Scoop. If these are your preferred install solutions, you can find the install instructions there. +
-## Third-Party Run Plugins +## ✨ What's new +**Version 0.96 (November 2025)** -There is a collection of [third-party plugins](./doc/thirdPartyRunPlugins.md) created by the community that aren't distributed with PowerToys. +For an in-depth look at the latest changes, visit the [Windows Command Line blog](https://aka.ms/powertoys-releaseblog). -## Contributing +**✨ Highlights** + - Advanced Paste now supports multiple online and on-device AI model providers: Azure OpenAI, OpenAI, Google Gemini, Mistral, Foundry Local and Ollama. + - Command Palette received extensive improvements including file search filters, better clipboard history metadata, context-menu styling, and dozens of bug fixes and enhancements. + - PowerRename can now extract and use photo metadata (EXIF, XMP) in renaming patterns like `%Camera`, `%Lens`, and `%ExposureTime`. -This project welcomes contributions of all types. Besides coding features / bug fixes, other ways to assist include spec writing, design, documentation, and finding bugs. We are excited to work with the power user community to build a set of tools for helping you get the most out of Windows. +### Advanced Paste + - Advanced Paste now lets you connect to multiple AI providers instead of being limited to a single OpenAI provider. See [Advanced Paste documentation](https://learn.microsoft.com/windows/powertoys/advanced-paste) for usage. -We ask that **before you start work on a feature that you would like to contribute**, please read our [Contributor's Guide](CONTRIBUTING.md). We would be happy to work with you to figure out the best approach, provide guidance and mentorship throughout feature development, and help avoid any wasted or duplicate effort. - -Most contributions require you to agree to a [Contributor License Agreement (CLA)][oss-CLA] declaring that you grant us the rights to use your contribution and that you have permission to do so. - -For guidance on developing for PowerToys, please read the [developer docs](./doc/devdocs) for a detailed breakdown. This includes how to setup your computer to compile. - -## What's Happening - -### PowerToys Roadmap - -Our [prioritized roadmap][roadmap] of features and utilities that the core team is focusing on. - -### 0.94 - Sep 2025 Update - -In this release, we focused on new features, stability, optimization improvements, and automation. - -For an in-depth look at the latest changes, visit the [release blog](https://aka.ms/powertoys-releaseblog). - -**✨Highlights** - - - PowerToys Settings added a Settings search with fuzzy matching, suggestions, a results page, and UX polish to make finding options faster. - - A comprehensive hotkey conflict detection system was introduced in Settings to surface and help resolve conflicting shortcuts. Note that the default hotkey settings (Win+Ctrl+Shift+T, Win+Ctrl+V, Win+Ctrl+T, Win+Shift+T) may overlap with existing Windows system shortcuts. This is expected. You can resolve the conflict by assigning different hotkeys. - - Mouse Utilities added a “Gliding cursor” accessibility feature to Mouse Pointer Crosshairs for single‑button cursor movement and clicking. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! - - The installer was upgraded to WiX 5 after WiX 3 reached end-of-life; this move improved installer security, reliability, and community support. - - Tons of bug fixes and improvements for Command Palette, including visual updates and new support for filters on ListPages (handy for extension developers). - - Hosts Editor now has a “No leading spaces” option so active host entries can start at column 0 even if others are disabled. Thanks [@mohammed-saalim](https://github.com/mohammed-saalim)! - - Context menu registration was moved from the installer to runtime to avoid loading disabled modules (runtime registrations). - - Quick Accent now supports Maltese, and frequently used accents appear first (and are remembered across sessions). Thanks [@rovercoder](https://github.com/rovercoder)! [@davidegiacometti](https://github.com/davidegiacometti)! - -### Always On Top - - - Fixed the border hover cursor so it shows the arrow instead of the wait cursor. Thanks [@davidegiacometti](https://github.com/davidegiacometti)! +### Awake + - The Awake countdown timer now stays accurate over long periods. Thanks [@daverayment](https://github.com/daverayment)! + - Fixed Awake context menu positioning. The fix removed the conversion of the mouse cursor from screen to client-window coordinates, instead using the raw screen coordinates returned by GetCursorPos; the context menu now appears at the correct screen position. Thanks [@lzandman](https://github.com/lzandman)! ### Command Palette + - The search field in context menus now matches the look of the Command Palette, with a smoke backdrop and improved padding. + - Fallback items such as math calculations or the Run command now appear in results more quickly. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Ensured the command bar updates correctly after navigating to another page and commands are displayed correctly. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - The Command Palette settings page has been reorganized. Activation-key options are grouped under an expander and extension settings are framed for improved readability. + - When you modify a command, its alias, hotkey, and tags now update in the top-level list, keeping the displayed information in sync. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Press `Ctrl + ,` to open Command Palette settings from anywhere. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - You can use `Page Up` and `Page Down` to navigate the list while focus is in the search box. Thanks [@samrueby](https://github.com/samrueby)! + - Fixed an issue where the search box could disappear when navigating pages. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Ensured search text is selected when *Go home when activated* and *Highlight search on activate* are both enabled. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Fixed an issue where Command Palette window occasionally appeared on the taskbar under certain Windows settings. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Ensured that labels and icons of list items and menu items update when they change. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Fixed visibility of list filters when navigating to a content page. Thanks [@DevLGuilherme](https://github.com/DevLGuilherme)! + - Added search to the extension list and a link to extensions on the Microsoft Store. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Added options to open the Command Palette window at its last position or re-center it. + - The Command Palette now remembers its window size after restarting. + - Added a global error handler that logs fatal errors and provides feedback when unexpected failures force Command Palette to close. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Fixed forms and extension settings not showing on some machines due to a missing VC++ runtime. + - Restored ranking of fallback commands for built-in extensions (Sleep, Shutdown, Windows settings, Web search, etc.). Thanks [@jiripolasek](https://github.com/jiripolasek). + - Improved and unified labels and texts across the application! + - Maintainance: Resolved numerous build warnings in Command Palette projects; no user-visible impact. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Maintainance: Fixed a logging issue so exception messages are properly recorded instead of placeholder text, improving troubleshooting. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Applied single-click activation only to pointer input; keyboard always activates immediately. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Let context menus open at the cursor by removing window-bound constraints. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Made error messages clearer with timestamps, HRESULTs, and full details for easier diagnosis. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Prevented crashes and improved robustness when updating providers without commands. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Ensured the Settings window reliably comes to the front when opened. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Replaced the Clipboard History icon with a colorful Fluent icon. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Hardened ContentIcon to avoid duplicate parenting and improve diagnostics. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Standardized null checks using C# pattern matching for safer behavior. - - Improved accessibility by focusing the activation shortcut dialog and making text reachable. Thanks [@chatasweetie](https://github.com/chatasweetie)! - - Moved the extension SDK to a stable Windows SDK and cleaned up message namespaces. - - Added path shortcuts: ~ to home, and / or \\ to system root, plus UNC support. Thanks [@davidegiacometti](https://github.com/davidegiacometti)! - - Fixed a race in cancellation handling to avoid InvalidOperationException. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Aligned separator styling with WinUI 3 for consistent visuals. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Added ARM64 PDBs to the Extensions SDK NuGet for better debugging. - - Added single-select filters to DynamicListPage and updated Windows Services sample. - - Updated main page placeholder text to better describe what can be searched. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Removed explicit WinAppSDK/WebView2 dependencies from toolkit and API. Thanks [@rluengen](https://github.com/rluengen)! - - Added a local keyboard hook to handle the GoBack key reliably. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Propagated alias changes safely and resolved conflicts across view models. - - Allowed providers to override Dispose with a virtual method. - - Fixed memory leaks by cleaning up removed or cancelled list items. - - Sorted DateTime extension results by relevance for better usability. - - Reduced search text “jiggling” by avoiding redundant change notifications. - - Centralized automation notifications in a UIHelper for better accessibility. Thanks [@chatasweetie](https://github.com/chatasweetie)! - - Preserved Adaptive Card action types during trimming via DynamicDependency. - - Added an acrylic backdrop and refined styling to the context menu. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Prevented disposed pages and Settings windows from handling stale messages. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Made the extension API easier to evolve without breaking clients. - - Added “evil” sample pages to help reproduce tricky bugs. - - Fixed WinGet trim-safety issues by replacing LINQ with manual iteration. - - Cancelled stale list fetches to avoid older results overwriting newer ones in CmdPal. +### Command Palette Extensions + - Bookmarks: Added hints about bookmark placeholders to the Add/Edit Bookmark form. — Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Bookmarks: Improved migration of bookmarks from older versions and fixed an issue where aliases or keyboard shortcuts could be lost after restart. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Clipboard history: Items shown in Command Palette’s clipboard history now include helpful metadata. For example, image items show dimensions, text files show names and sizes, web links include page titles, and text entries display word counts. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - File search: Added filter buttons to show *all items*, *files only*, or *folders only*. Selecting a filter adds `kind:folders` or `kind:not folders` to narrow results. + - System commands: Replaced the `:red_circle:` placeholder with an actual red-circle emoji so the correct icon appears in the UI. Thanks [@samrueby](https://github.com/samrueby)! + - WinGet: Search performance feels more responsive because typed input is now processed via a task queue rather than complex cancellation tokens! + - Window Walker: UWP apps no longer show a "not responding" label when suspended. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Window Walker: Now displays the actual icon of each window rather than using the process icon, improving recognition of PWAs and Python GUIs. Thanks [@Lee-WonJun](https://github.com/Lee-WonJun)! +- Windows Terminal profiles: Fixed a rare crash in the Windows Terminal extension when the `LOCALAPPDATA` environment variable was missing. The path is now retrieved via a reliable API. Thanks [@jiripolasek](https://github.com/jiripolasek)! -### Command Palette extensions - - - Improved empty states and ranking logic for multiple extensions. Thanks [@htcfreek](https://github.com/htcfreek)! - - Added app icons to the All Apps "Run" context command when available. - - Restored missing builtin icons by standardizing extension dependencies. - - Unblocked local deployment by adding WinAppSDK to two sample extensions. +### Find My Mouse + - Activating Find My Mouse no longer makes the cursor change to the busy (hourglass) icon or steals focus from your active application. ### Hosts File Editor - - - Added a "No leading spaces" option so active hosts entries can start at column 0 even when others are disabled. Thanks [@mohammed-saalim](https://github.com/mohammed-saalim)! + - Added customizable backup settings allowing users to configure backup frequency, location, and auto-deletion policies. Thanks [@davidegiacometti](https://github.com/davidegiacometti)! ### Image Resizer + - Fixed settings consistency during batch resize operations by capturing settings once before processing. Thanks [@daverayment](https://github.com/daverayment)! - - Fixed Image Resizer localization by installing satellite resources under the WinUI 3 apps culture path. +### Light Switch +- Introduced new UI to allow users to manually enter their latitude and longitude in Sunrise to Sunset mode. +- Refactored service with cleaner state management for stability. +- Removed logs from every tick, only logging key events to largely reduce log size. -### Mouse Utilities - - - Introduced "Gliding cursor" to control the pointer and click with a single hotkey for better accessibility. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! +### Mouse Pointer Crosshairs + - Enabled switching between Mouse Pointer Crosshairs and Gliding Cursor modes. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! ### Mouse Without Borders - - - Blocked Easy Mouse from switching machines during fullscreen apps, with an allow-list for exceptions. Thanks [@dot-tb](https://github.com/dot-tb)! + - Added horizontal scrolling support. Thanks [@MasonBergstrom](https://github.com/MasonBergstrom)! ### Peek - - - Added Visual Studio shared project file types to XML preview and fixed bgcode handler registration. Thanks [@rezanid](https://github.com/rezanid)! - - Fixes bgcode preview handler registration and events for reliable previews. Thanks [@pedrolamas](https://github.com/pedrolamas)! +- Fixed media files remaining locked after preview window closes. Thanks [@daverayment](https://github.com/daverayment)! +- Added a command-line interface for file previewing. See the [Peek documentation](https://learn.microsoft.com/windows/powertoys/peek) for usage. Thanks [@prochan2](https://github.com/prochan2)! ### PowerRename +- PowerRename no longer crashes due to a missing resources file. +- Added photo metadata extraction support using EXIF and XMP for pattern-based renaming with camera info, GPS coordinates, and date taken. See [PowerRename Documentation](https://learn.microsoft.com/en-us/windows/powertoys/powerrename). - - Changed the Explorer accelerator key to PowErRename to avoid clashing with the New menu. Thanks [@aaron-ni](https://github.com/aaron-ni)! +### PowerToys Run + - Added retry logic with exponential backoff to handle DWM composition errors during theme changes. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Updated OneNote icons to reflect new Microsoft 365 design. Thanks [@trevorNgo](https://github.com/trevorNgo)! -### Quick Accent + ### Quick Accent + - Added diameter symbol (⌀) for Shift+O in Special Characters mode, thanks to [@anselumjuju](https://github.com/anselumjuju)! - - Remembered character usage across sessions so frequently used accents appear first. Thanks [@davidegiacometti](https://github.com/davidegiacometti)! - - Added Maltese language support with specific characters and the Euro symbol. Thanks [@rovercoder](https://github.com/rovercoder)! - - Reduced GPU usage issues by making the window Topmost only when the picker is visible. Thanks [@daverayment](https://github.com/daverayment)! +### Zoomit + - Smoothed out zoom-animation in ZoomIt by coalescing mouse-move and timer events, thanks to [@foxmsft](https://github.com/foxmsft)! + - Enabled GIF support for ZoomIt, thanks to [@MarioHewardt](https://github.com/MarioHewardt)! + - Fixed spelling mistakes, and refactored some literal strings to string constants, thanks to [@lzandman](https://github.com/lzandman)! + - Fixed inaccurate "actual size" screenshots in ZoomIt and resolves a GDI handle leak, improving capture fidelity and long-session stability. thanks to [@daverayment](https://github.com/daverayment)! ### Settings - - - Added telemetry to track usage of the new shortcut conflict detection workflow. - - Moved the shutdown action from the title bar to a footer menu item with confirmation. Thanks [@davidegiacometti](https://github.com/davidegiacometti)! - - Implemented comprehensive hotkey conflict detection with a dedicated resolution dialog. - - Added branded visuals for Office and Copilot keys in the KeyVisual control. - - Introduced Settings search with fuzzy matching and navigation to specific controls. - - Corrected Spanish localization so product names like Awake remain in English across Settings and OOBE. - - Simplified the Advanced Paste description in Settings for quicker reading and consistent capitalization. Thanks [@OldUser101](https://github.com/OldUser101)! - - Localized conflict messages in the conflict window and dialog. - -### Installer - - - Upgraded the installer to WiX 5 with silent "Files in Use" handling for smoother winget installs. - - Switched Win10 context menu modules to runtime registration and added cleanup on uninstall to avoid stale entries. - -### Documentation - - - Adds docs for building the installer locally and testing winget installs. - - Fixed a broken style guide link in developer documentation. Thanks [@denizmaral](https://github.com/denizmaral)! +- Fixed title bar overlapping issue at smaller window sizes. +- Refined shortcut control visual design with improved consistency and spacing. +- Added dashboard utilities sorting by name or status. +- Made update notification InfoBar in flyout clickable for direct navigation to update page. +- Expanded installation instructions by default in README. +- Improved accessibility for shortcut conflict button with static resource-based automation properties. +- Added ScrollViewer to Command Palette page in PowerToys Settings. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Fixed module list glitches and Sort Status checkmark issue. Thanks [@daverayment](https://github.com/daverayment)! ### Development +- Fixed accessibility by associating controls with labels for screen readers. +- Added accessible name to Shortcut Conflicts button for screen readers. +- Excluded TitleBars from tab navigation across multiple utilities. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Migrated build infrastructure from Windows Server 2019 to Server 2022 with improved failure logging and predictable NuGet package paths. +- Configured build agents to use larger P: drive for release builds to address disk space constraints. +- Enhanced DSC v3 support by organizing resource manifests in a dedicated subfolder with PATH configuration. +- Reduced installer bundle size by 6-7MB through centralized Hybrid CRT configuration across all C++ projects. +- Updated .NET packages to version 9.0.10 for security fixes. Thanks [@snickler](https://github.com/snickler)! +- Fixed spell check dictionary entries for consistency. +- Restored accidentally deleted NuGet configuration file for Command Palette extensions. +- Fixed package identity build by updating AppxManifest entry points to use PowerShell Core. +- Optimized CI pipeline by replacing file copy operations with hard links and moves, reducing build time and disk usage by 10-15GB. +- Updated Copilot guidance and PR prompt workflow. +- Included high-volume bugs in issue template header. Thanks [@daverayment](https://github.com/daverayment)! +- Fixed incorrect HRESULT logging for inner exceptions. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Introduced shared sparse package identity for PowerToys Win32 components to enable access to Windows platform APIs. +- Consolidated installer builds to produce both machine and user installers simultaneously, reducing build time and complexity. +- Migrated exclusively to WiX v5 installer infrastructure, removing legacy WiX v3 support. +- Temporarily removed PowerToys installer path from PATH environment variable to prevent application crashes. +- Added complete OCR UI test coverage with automated tests for activation, settings, language selection, and text extraction. +- Fixed test input for drive path normalization in bookmark resolver unit tests. +- Fixed Peek UI tests by restoring Ctrl+Space activation shortcut for test scenarios. +- Hided apps in PowerToys.SpareApps package from Start Menu. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Excluded test and coverage DLLs from BinSkim scans to cut false positives and speed up security analysis. - - Simplified NOTICE maintenance by removing version numbers and filtering out Microsoft/System packages. - - Improved NuGet dependency validation to prevent package downgrades and catch issues during restore. - - Updated UTF.Unknown to a modern version to improve compatibility without breaking changes. Thanks [@304NotModified](https://github.com/304NotModified)! - - Refreshed package catalog in CI before installing dependencies to prevent Linux workflow failures. - - Refactored CmdPal tests with dependency injection and added coverage for queries and settings. - - Added unit tests to verify Close on Enter swaps Copy/Save as expected. Thanks [@mohammed-saalim](https://github.com/mohammed-saalim)! - - Added accessibility IDs to CmdPal UI for stable UI tests. - - Rewrote system command tests with a new test base and cleaner patterns. - - Added unit tests for WebSearch and Shell extensions with mockable settings. - - Added unit tests and abstractions for Apps and Bookmarks extensions. - - Cleans up AI‑generated tests; adds meaningful query tests across extensions. - - Removed the obsolete debug dialog from Settings for a smoother developer loop. +## 🛣️ Roadmap +We are planning some nice new features and improvements for the next releases – a revamped Keyboard Manager UI, custom endpoint and local model support for Advanced Paste, Command Palette improvements and a brand-new Shortcut Guide experience! Stay tuned for [v0.96][github-next-release-work]! -### What is being planned over the next few releases +## ❤️ PowerToys Community +The PowerToys team is extremely grateful to have the [support of an amazing active community][community-link]. The work you do is incredibly important. PowerToys wouldn't be nearly what it is today without your help filing bugs, updating documentation, guiding the design, or writing features. We want to say thank you and take time to recognize your work. Your contributions and feedback improve PowerToys month after month! -For [v0.95][github-next-release-work], we'll work on the items below: +## Contributing +This project welcomes contributions of all types. Besides coding features / bug fixes, other ways to assist include spec writing, design, documentation, and finding bugs. We are excited to work with the power user community to build a set of tools for helping you get the most out of Windows. We ask that **before you start work on a feature that you would like to contribute**, please read our [Contributor's Guide](CONTRIBUTING.md). We would be happy to work with you to figure out the best approach, provide guidance and mentorship throughout feature development, and help avoid any wasted or duplicate effort. Most contributions require you to agree to a [Contributor License Agreement (CLA)][oss-CLA] declaring that you grant us the rights to use your contribution and that you have permission to do so. For guidance on developing for PowerToys, please read the [developer docs](./doc/devdocs) for a detailed breakdown. This includes how to setup your computer to compile. - - Continued Command Palette polish - - Working on Shortcut Guide v2 (Thanks [@noraa-junker](https://github.com/noraa-junker)!) - - Upgrading Keyboard Manager's editor UI - - UI tweaking utility with day/night theme switcher - - DSC v3 support for top utilities - - New UI automation tests - - Stability, bug fixes +## Code of Conduct +This project has adopted the [Microsoft Open Source Code of Conduct][oss-conduct-code]. -## PowerToys Community +## Privacy Statement +The application logs basic diagnostic data (telemetry). For more privacy information and what we collect, see our [PowerToys Data and Privacy documentation](https://aka.ms/powertoys-data-and-privacy-documentation). -The PowerToys team is extremely grateful to have the [support of an amazing active community][community-link]. The work you do is incredibly important. PowerToys wouldn’t be nearly what it is today without your help filing bugs, updating documentation, guiding the design, or writing features. We want to say thank you and take time to recognize your work. Month by month, you directly help make PowerToys a better piece of software. - -## Code of Conduct - -This project has adopted the [Microsoft Open Source Code of Conduct][oss-conduct-code]. - -## Privacy Statement - -The application logs basic diagnostic data (telemetry). For more privacy information and what we collect, see our [PowerToys Data and Privacy documentation](https://aka.ms/powertoys-data-and-privacy-documentation). - -[oss-CLA]: https://cla.opensource.microsoft.com -[oss-conduct-code]: CODE_OF_CONDUCT.md -[community-link]: COMMUNITY.md -[github-release-link]: https://aka.ms/installPowerToys -[microsoft-store-link]: https://aka.ms/getPowertoys -[winget-link]: https://github.com/microsoft/winget-cli#installing-the-client +[oss-CLA]: https://cla.opensource.microsoft.com +[oss-conduct-code]: CODE_OF_CONDUCT.md +[community-link]: COMMUNITY.md +[github-release-link]: https://aka.ms/installPowerToys +[microsoft-store-link]: https://aka.ms/getPowertoys +[winget-link]: https://github.com/microsoft/winget-cli#installing-the-client [roadmap]: https://github.com/microsoft/PowerToys/wiki/Roadmap -[privacy-link]: http://go.microsoft.com/fwlink/?LinkId=521839 -[loc-bug]: https://github.com/microsoft/PowerToys/issues/new?assignees=&labels=&template=translation_issue.md&title= +[privacy-link]: http://go.microsoft.com/fwlink/?LinkId=521839 +[loc-bug]: https://github.com/microsoft/PowerToys/issues/new?assignees=&labels=&template=translation_issue.md&title= [usingPowerToys-docs-link]: https://aka.ms/powertoys-docs diff --git a/deps/cziplib b/deps/cziplib deleted file mode 160000 index 81314fff0a..0000000000 --- a/deps/cziplib +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 81314fff0a882b72a9ad321e7a3311660125b56e diff --git a/doc/devdocs/core/installer.md b/doc/devdocs/core/installer.md index 8e9f008f2e..5bcbb0f87c 100644 --- a/doc/devdocs/core/installer.md +++ b/doc/devdocs/core/installer.md @@ -1,6 +1,6 @@ # PowerToys Installer -## Installer Architecture (WiX 3/ WiX 5) +## Installer Architecture (WiX 5) - Uses a bootstrapper to check dependencies and close PowerToys - MSI defined in product.wxs @@ -22,7 +22,7 @@ ### MSI Installer Build Process -- First builds `PowerToysSetupCustomActions` DLL and signs it, for WiX5 project, installer will build `PowerToysSetupCustomActionsVNext` DLL. +- First builds `PowerToysSetupCustomActionsVNext` DLL and signs it - Then builds the installer without cleaning, to reuse the signed DLL - Uses PowerShell scripts to modify .wxs files before build - Restores original .wxs files after build completes @@ -96,9 +96,14 @@ The following manual steps will not install the MSIX apps (such as Command Palet #### Prerequisites for building the MSI installer -1. Install the [WiX Toolset Visual Studio 2022 Extension](https://marketplace.visualstudio.com/items?itemName=WixToolset.WixToolsetVisualStudio2022Extension). -1. Install the [WiX Toolset build tools](https://github.com/wixtoolset/wix3/releases/tag/wix3141rtm). (installer [direct link](https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe)) -1. Download [WiX binaries](https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip) and extract `wix.targets` to `C:\Program Files (x86)\WiX Toolset v3.14`. +PowerToys uses WiX v5 for creating installers. The WiX v5 tools are automatically installed during the build process via dotnet tool. + +For manual installation of WiX v5 tools: +```powershell +dotnet tool install --global wix --version 5.0.2 +``` + +> **Note:** As of release 0.94, PowerToys has migrated from WiX v3 to WiX v5. The WiX v3 toolset is no longer required. #### Building prerequisite projects @@ -133,17 +138,9 @@ If you prefer, you can alternatively build prerequisite projects for the install 1. In Visual Studio, in the `Solutions Configuration` drop-down menu select `Release` 1. From the `Build` menu choose `Build Solution`. -The resulting `PowerToysSetup.msi` installer will be available in the `installer\PowerToysSetup\x64\Release\` folder. +The resulting installer will be available in the `installer\PowerToysSetupVNext\x64\Release\` folder. -For WiX3 project, run `Developer Command Prompt for VS 2022` in admin mode and execute the following command to build the installer. The generated installer package will be located at `\installer\PowerToysSetup\{platform}\Release\MachineSetup`. - -``` -git clean -xfd -e *exe -- .\installer\ -MSBuild -t:restore .\installer\PowerToysSetup.sln -p:RestorePackagesConfig=true /p:Platform="x64" /p:Configuration=Release -MSBuild -m .\installer\PowerToysSetup.sln /t:PowerToysInstaller /p:Configuration=Release /p:Platform="x64" -MSBuild -m .\installer\PowerToysSetup.sln /t:PowerToysBootstrapper /p:Configuration=Release /p:Platform="x64" -``` -For WiX5 project, run `Developer Command Prompt for VS 2022` in admin mode and execute the following command to build the installer. The generated installer package will be located at `\installer\PowerToysSetupVNext\{platform}\Release\MachineSetup`. +To build the installer from the command line, run `Developer Command Prompt for VS 2022` in admin mode and execute the following commands. The generated installer package will be located at `\installer\PowerToysSetupVNext\{platform}\Release\MachineSetup`. ``` git clean -xfd -e *exe -- .\installer\ diff --git a/doc/devdocs/development/dev-with-vscode.md b/doc/devdocs/development/dev-with-vscode.md new file mode 100644 index 0000000000..4b1b7def24 --- /dev/null +++ b/doc/devdocs/development/dev-with-vscode.md @@ -0,0 +1,128 @@ +## Developing PowerToys with Visual Studio Code + +This guide shows how to build, debug, and contribute to PowerToys using VS Code instead of (or alongside) full Visual Studio. It focuses on common inner‑loop tasks for C++, .NET, and mixed scenarios present in the solution. + +> PowerToys is a large mixed C++ / C# / WinAppSDK solution. VS Code works well for incremental development and quick module iterations, but occasionally you may still prefer full Visual Studio for designer tooling or specialized diagnostics. + +--- +VS Code extensions Needed: + +| Area | Extension | Notes | +|------|-----------|-------| +| C++ | ms-vscode.cpptools | IntelliSense, debugging (cppvsdbg) | +| C# | ms-dotnettools.csdevkit (or C#) | Language service / test explorer | + +--- + +## Building in VS Code +### Configure developer powershell for vs2022 for more convenient dev in vscode. +1. Configure profile in in settings, entry: "terminal.integrated.profiles.windows" +2. Add below config as entry: +```json + "Developer PowerShell for VS 2022": { + // Configure based on your preference + "path": "C:\\Program Files\\WindowsApps\\Microsoft.PowerShell_7.5.2.0_arm64__8wekyb3d8bbwe\\pwsh.exe", + "args": [ + "-NoExit", + "-Command", + "& {", + "$orig = Get-Location;", + // Configure based on your environment + "& 'C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise\\Common7\\Tools\\Launch-VsDevShell.ps1';", + "Set-Location $orig", + "}" + ] + }, +``` +3. [Optional] Set Developer PowerShell for VS 2022 as your default profile, so that you can get a deep integration with vscode coding agent. + +4. Now You can build with plain `msbuild` or configure tasks.json in below section +Or reach out to "tools\build\BUILD-GUIDELINES.md" + +### Sample plain msbuild command +```powershell +# Restore: +msbuild powertoys.sln -t:restore -p:configuration=debug -p:platform=x64 -m + +# Build powertoys sln +msbuild powertoys.sln -p:configuration=debug -p:platform=x64 -m + +# dotnet project +msbuild src\settings-ui\Settings.UI\PowerToys.Settings.csproj -p:Platform=x64 -p:Configuration=Debug -m + +# native project +msbuild "src\modules\MouseUtils\FindMyMouse\FindMyMouse.vcxproj" -p:Configuration=Debug -p:Platform=x64 -m +``` + +--- + +## Debugging + +### Existing launch configuration + +The repo provides `.vscode/launch.json` with: + +- `Run PowerToys.exe (no build)`: Launches the already-built executable at `x64/Debug/PowerToys.exe` using `cppvsdbg`. + +Build first, then press F5. To switch configuration (Release / ARM64) either edit the path or create additional launch entries. + +### Attaching to a running instance + +If PowerToys is already running, you can attach to that process: + +2. VS Code command palette: “C/C++: (Windows) Attach to Process”. +3. Filter for `PowerToys.exe` / module-specific processes. + +### Debugging managed components + +Many modules have a managed component loaded into the PowerToys process. `cppvsdbg` can debug mixed mode, but if you need richer .NET inspection you can create a second configuration using `type: coreclr` and `processId` attachment after the native launch, or just attach separately: + +Similar for attach to managed code. +> Note: In arm64 machine, can only debug arm64 code. + +```jsonc +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run native executable (no build)", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}\\x64\\Debug\\PowerToys.exe", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "console": "integratedTerminal" + }, + { + "name": "C/C++ Attach to PowerToys Process (native)", + "type": "cppvsdbg", + "request": "attach", + "processId": "${command:pickProcess}", + "symbolSearchPath": "${workspaceFolder}\\x64\\Debug;${workspaceFolder}\\Debug;${workspaceFolder}\\symbols" + }, + { + "name": "Run managed code (managed, no build)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}\\arm64\\Debug\\WinUI3Apps\\PowerToys.Settings.exe", + "args": [], + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole", + "stopAtEntry": false + } + ] +} +``` +--- + +## 6. Common tasks & tips + +| Task | Command / Action | Notes | +|------|------------------|-------| +| Clean | `git clean -xdf` (careful) or `msbuild /t:Clean PowerToys.sln` | Deep clean removes packages & build outputs | +| Rebuild single project | `msbuild path\to\proj.vcxproj /t:Rebuild -p:Platform=x64 -p:Configuration=Debug` | Faster than whole solution | +| Generate installer (rare in inner loop) | See `tools\build\build-installer.ps1` | Usually not needed for local debug | +| Resource conversion errors | Re-run restore + build | Triggers custom PowerShell targets | \ No newline at end of file diff --git a/doc/devdocs/UITests.md b/doc/devdocs/development/ui-tests.md similarity index 100% rename from doc/devdocs/UITests.md rename to doc/devdocs/development/ui-tests.md diff --git a/doc/devdocs/modules/lightswitch.md b/doc/devdocs/modules/lightswitch.md new file mode 100644 index 0000000000..1e251dfff1 --- /dev/null +++ b/doc/devdocs/modules/lightswitch.md @@ -0,0 +1,107 @@ +# Light Switch + +[Public Overview – Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/light-switch) + +## Quick Links + +* [All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue%20state%3Aopen%20label%3AProduct-LightSwitch) +* [Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue%20state%3Aopen%20label%3AProduct-LightSwitch%20label%3AIssue-Bug) +* [Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-LightSwitch) + +## Overview + +The **Light Switch** module lets users automatically transition between light and dark mode using a timed schedule or a keyboard shortcut. + +## Features + +* Set custom times to start and stop dark mode. +* Use geolocation to determine local sunrise and sunset times. +* Apply offsets in sunrise mode (e.g., 15 minutes before sunset). +* Quickly toggle between modes with a keyboard shortcut (`Ctrl+Shift+Win+D` by default). +* Choose whether theme changes apply to: + + * Apps only + * System only + * Both apps and system + +## Architecture + +### Main Components + +* **Shortcut/Hotkey** + Listens for a hotkey event. Calling `onHotkey()` flips the theme flags. + + > **Note:** Using the shortcut overrides the current schedule until the next transition event. + +* **LightSwitchService** + Reads settings and applies theming. Runs a check every minute to ensure the state is correct. + +* **SettingsXAML/LightSwitch** + Provides the settings UI for configuring schedules, syncing location, and customizing shortcuts. + +* **Settings.UI/ViewModels/LightSwitchViewModel.cs** + Handles updates to the settings file and communicates changes to the front end. + +* **modules/LightSwitch/Tests** + Contains UI tests that verify interactions between the settings UI, system state, and `settings.json`. + +### Data Flow + +1. User configures settings in the UI (default: manual mode, light mode from 06:00–18:00). +2. Every minute, the service checks the time. + + * If it’s not a threshold, the service sleeps until the next minute. + * If it matches a threshold, the service applies the theme based on settings and returns to sleep. +3. At **midnight**, when in *Sunrise to Sunset* mode, the service updates daily sunrise and sunset times. +4. If the machine was asleep during a scheduled event, the service applies the correct settings at the next check. + +## User Interface + +The module’s settings are exposed in the PowerToys Settings UI. Options include: + +* Shortcut customization +* Mode selection (Manual or Sunrise to Sunset) +* Manual start/stop times (manual mode only) +* Automatic sunrise/sunset calculation (location-based) +* Time offsets (sunrise mode) +* Target scope (system, apps, or both) + +## Development Environment Setup + +### Prerequisites + +* Visual Studio 2019 or later +* Windows 10 SDK +* PowerToys repository cloned from GitHub + +### Building and Testing + +1. Clone the repo: + + ```sh + git clone https://github.com/microsoft/PowerToys.git + ``` +2. Initialize submodules: + + ```sh + git submodule update --init --recursive + ``` +3. Build the solution: + + ```sh + msbuild -restore -p:RestorePackagesConfig=true -p:Platform=ARM64 -m PowerToys.sln + ``` + + > Note: This may take some time. +4. Set `runner` as the startup project and press **F5**. +5. Enable Light Switch in PowerToys Settings. +6. To debug the service: + + * Press `Ctrl+Alt+P` or go to **Debug > Attach to Process**. + * Select `LightSwitchService.exe` and click **Attach**. + * You can now set breakpoints in the service files. +7. To debug the Settings UI: + + * Set the startup project to `PowerToys.Settings` and press **F5**. + * Note: Light Switch settings will not persist in this mode (they depend on the service executable). + * Alternatively, you can attach `PowerToys.Settings.exe` to the debugger while `runner` is running to test the full flow with breakpoints. diff --git a/doc/dsc/Settings.md b/doc/dsc/Settings.md new file mode 100644 index 0000000000..24fab1e2dd --- /dev/null +++ b/doc/dsc/Settings.md @@ -0,0 +1,83 @@ +# Settings resource +Manage the settings for PowerToys modules + +## Commands + +### ✨ Modules +List all the modules supported by the settings resource. +```shell +PS C:\> PowerToys.DSC.exe modules --resource 'settings' +AdvancedPaste +AlwaysOnTop +App +Awake +ColorPicker +CropAndLock +EnvironmentVariables +FancyZones +FileLocksmith +FindMyMouse +Hosts +ImageResizer +KeyboardManager +MeasureTool +MouseHighlighter +MouseJump +MousePointerCrosshairs +Peek +PowerAccent +PowerOCR +PowerRename +RegistryPreview +ShortcutGuide +Workspaces +ZoomIt +``` + +### 📄 Get +Get the settings for a specific module. +```shell +PS C:\> PowerToys.DSC.exe get --resource 'settings' --module EnvironmentVariables +{"settings":{"properties":{"LaunchAdministrator":{"value":true}},"name":"EnvironmentVariables","version":"1.0"}} +``` + +### 🖨️ Export +Export the settings for a specific module. + +ℹ️ Settings resource Get and Export operation output states are identical. +```shell +PS C:\> PowerToys.DSC.exe get --resource 'settings' --module EnvironmentVariables +{"settings":{"properties":{"LaunchAdministrator":{"value":true}},"name":"EnvironmentVariables","version":"1.0"}} +``` + +### 📝 Set +Set the settings for a specific module. This command will update the settings to the specified values. +```shell +PS C:\> PowerToys.DSC.exe set --resource 'settings' --module Awake --input '{"settings":{"properties":{"keepDisplayOn":false,"mode":0,"intervalHours":0,"intervalMinutes":1,"expirationDateTime":"2025-08-13T10:10:00.000001-07:00","customTrayTimes":{}},"name":"Awake","version":"0.0.1"}}' +{"settings":{"properties":{"keepDisplayOn":false,"mode":0,"intervalHours":0,"intervalMinutes":1,"expirationDateTime":"2025-08-13T10:10:00.000001-07:00","customTrayTimes":{}},"name":"Awake","version":"0.0.1"}} +["settings"] +``` + +### 🧪 Test +Test the settings for a specific module. This command will check if the current settings match the desired state. +```shell +PS C:\> PowerToys.DSC.exe test --resource 'settings' --module Awake --input '{"settings":{"properties":{"keepDisplayOn":false,"mode":0,"intervalHours":0,"intervalMinutes":1,"expirationDateTime":"2025-08-13T10:10:00.000002-07:00","customTrayTimes":{}},"name":"Awake","version":"0.0.1"}}' +{"settings":{"properties":{"keepDisplayOn":false,"mode":0,"intervalHours":0,"intervalMinutes":1,"expirationDateTime":"2025-08-13T10:10:00.000001-07:00","customTrayTimes":{}},"name":"Awake","version":"0.0.1"},"_inDesiredState":false} +["settings"] +``` + +### 🛠️ Schema +Generates the JSON schema for the settings resource of a specific module. +```shell +PS C:\> PowerToys.DSC.exe schema --resource 'settings' --module Awake +{"$schema":"http://json-schema.org/draft-04/schema#","title":"SettingsResourceObjectOfAwakeSettings","type":"object","additionalProperties":false,"required":["settings"],"properties":{"_inDesiredState":{"type":["boolean","null"],"description":"Indicates whether an instance is in the desired state"},"settings":{"description":"The settings content for the module."}}} +PS E:\src\powertoys> PowerToys.DSC.exe schema --resource 'settings' --module Awake | Format-Json +``` + +### 📦 Manifest +Generates a manifest dsc resource JSON file for the specified module. +- If the module is not specified, it will generate a manifest for all modules. +- If the output directory is not specified, it will print the manifest to the console. +```shell +PS C:\> PowerToys.DSC.exe manifest --resource settings --module 'Awake' --outputDir "C:\manifests" +``` \ No newline at end of file diff --git a/doc/images/icons/Command Palette.png b/doc/images/icons/Command Palette.png new file mode 100644 index 0000000000..7360fdd113 Binary files /dev/null and b/doc/images/icons/Command Palette.png differ diff --git a/doc/images/icons/CursorWrap.png b/doc/images/icons/CursorWrap.png new file mode 100644 index 0000000000..20db84fc9a Binary files /dev/null and b/doc/images/icons/CursorWrap.png differ diff --git a/doc/images/icons/Find My Mouse.png b/doc/images/icons/Find My Mouse.png index 71dd994569..82fbe59800 100644 Binary files a/doc/images/icons/Find My Mouse.png and b/doc/images/icons/Find My Mouse.png differ diff --git a/doc/images/icons/Light Switch.png b/doc/images/icons/Light Switch.png new file mode 100644 index 0000000000..8a0778ff05 Binary files /dev/null and b/doc/images/icons/Light Switch.png differ diff --git a/doc/images/icons/Mouse Highlighter.png b/doc/images/icons/Mouse Highlighter.png index b06843d941..0feb5cc15a 100644 Binary files a/doc/images/icons/Mouse Highlighter.png and b/doc/images/icons/Mouse Highlighter.png differ diff --git a/doc/images/icons/MouseJump.png b/doc/images/icons/MouseJump.png new file mode 100644 index 0000000000..2fbe450ac2 Binary files /dev/null and b/doc/images/icons/MouseJump.png differ diff --git a/doc/images/icons/MouseWithoutBorders.png b/doc/images/icons/MouseWithoutBorders.png index a29adf7d11..ee66893cbd 100644 Binary files a/doc/images/icons/MouseWithoutBorders.png and b/doc/images/icons/MouseWithoutBorders.png differ diff --git a/doc/images/icons/ZoomIt.png b/doc/images/icons/ZoomIt.png new file mode 100644 index 0000000000..777a30bd1f Binary files /dev/null and b/doc/images/icons/ZoomIt.png differ diff --git a/doc/images/overview/LightSwitch_large.png b/doc/images/overview/LightSwitch_large.png new file mode 100644 index 0000000000..3a98b7f3e2 Binary files /dev/null and b/doc/images/overview/LightSwitch_large.png differ diff --git a/doc/images/overview/LightSwitch_small.png b/doc/images/overview/LightSwitch_small.png new file mode 100644 index 0000000000..c6e94735a9 Binary files /dev/null and b/doc/images/overview/LightSwitch_small.png differ diff --git a/doc/images/overview/Original/Light Switch.png b/doc/images/overview/Original/Light Switch.png new file mode 100644 index 0000000000..04e551a85d Binary files /dev/null and b/doc/images/overview/Original/Light Switch.png differ diff --git a/doc/images/overview/PT_hero_image.png b/doc/images/overview/PT_hero_image.png deleted file mode 100644 index 026a456297..0000000000 Binary files a/doc/images/overview/PT_hero_image.png and /dev/null differ diff --git a/doc/images/overview/PT_large.png b/doc/images/overview/PT_large.png deleted file mode 100644 index 340cde5283..0000000000 Binary files a/doc/images/overview/PT_large.png and /dev/null differ diff --git a/doc/images/overview/PT_small.png b/doc/images/overview/PT_small.png deleted file mode 100644 index 4c66f43b62..0000000000 Binary files a/doc/images/overview/PT_small.png and /dev/null differ diff --git a/doc/images/readme/StoreBadge-dark.png b/doc/images/readme/StoreBadge-dark.png new file mode 100644 index 0000000000..8095159a82 Binary files /dev/null and b/doc/images/readme/StoreBadge-dark.png differ diff --git a/doc/images/readme/StoreBadge-light.png b/doc/images/readme/StoreBadge-light.png new file mode 100644 index 0000000000..fc4c9aa8eb Binary files /dev/null and b/doc/images/readme/StoreBadge-light.png differ diff --git a/doc/images/readme/pt-hero.dark.png b/doc/images/readme/pt-hero.dark.png new file mode 100644 index 0000000000..e0ac68155a Binary files /dev/null and b/doc/images/readme/pt-hero.dark.png differ diff --git a/doc/images/readme/pt-hero.light.png b/doc/images/readme/pt-hero.light.png new file mode 100644 index 0000000000..8cdda7b92f Binary files /dev/null and b/doc/images/readme/pt-hero.light.png differ diff --git a/doc/thirdPartyRunPlugins.md b/doc/thirdPartyRunPlugins.md index eccdc3530e..a15cb542a8 100644 --- a/doc/thirdPartyRunPlugins.md +++ b/doc/thirdPartyRunPlugins.md @@ -50,6 +50,7 @@ Contact the developers of a plugin directly for assistance with a specific plugi | [Hotkeys](https://github.com/ruslanlap/PowerToysRun-Hotkeys) | [ruslanlap](https://github.com/ruslanlap) | Create, manage, and trigger custom keyboard shortcuts directly from PowerToys Run. | | [RandomGen](https://github.com/ruslanlap/PowerToysRun-RandomGen) | [ruslanlap](https://github.com/ruslanlap) | 🎲 Generate random data instantly with a single keystroke. Perfect for developers, testers, designers, and anyone who needs quick access to random data. Features include secure passwords, PINs, names, business data, dates, numbers, GUIDs, color codes, and more. Especially useful for designers who need random color codes and placeholder content. | | [Open With Cursor](https://github.com/VictorNoxx/PowerToys-Run-Cursor/) | [VictorNoxx](https://github.com/VictorNoxx) | Open Visual Studio, VS Code recents with Cursor AI | +| [CheatSheets](https://github.com/ruslanlap/PowerToysRun-CheatSheets) | [ruslanlap](https://github.com/ruslanlap) | 📚 Find cheat sheets and command examples instantly from tldr pages, cheat.sh, and devhints.io. Features include favorites system, categories, offline mode, and smart caching. | ## Extending software plugins diff --git a/installer/PowerToysSetup.sln b/installer/PowerToysSetup.sln index 47d0c56070..77d38c94ab 100644 --- a/installer/PowerToysSetup.sln +++ b/installer/PowerToysSetup.sln @@ -2,16 +2,10 @@ # Visual Studio Version 17 VisualStudioVersion = 17.1.32414.318 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "PowerToysInstaller", "PowerToysSetup\PowerToysInstaller.wixproj", "{022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerToysSetupCustomActions", "PowerToysSetupCustomActions\PowerToysSetupCustomActions.vcxproj", "{32F3882B-F2D6-4586-B5ED-11E39E522BD3}" -EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "spdlog", "..\src\logging\logging.vcxproj", "{7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}" EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "logger", "..\src\common\logger\logger.vcxproj", "{D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}" EndProject -Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "PowerToysBootstrapper", "PowerToysSetup\PowerToysBootstrapper.wixproj", "{31D72625-43C1-41B1-B784-BCE4A8DC5543}" -EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Version", "..\src\common\version\version.vcxproj", "{CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}" EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EtwTrace", "..\src\common\Telemetry\EtwTrace\EtwTrace.vcxproj", "{8F021B46-362B-485C-BFBA-CCF83E820CBD}" @@ -32,21 +26,6 @@ Global Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Debug|ARM64.Build.0 = Debug|ARM64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Debug|x64.ActiveCfg = Debug|x64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Debug|x64.Build.0 = Debug|x64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Release|ARM64.ActiveCfg = Release|ARM64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Release|ARM64.Build.0 = Release|ARM64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Release|x64.ActiveCfg = Release|x64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Release|x64.Build.0 = Release|x64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Debug|x64.ActiveCfg = Debug|x64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Debug|x64.Build.0 = Debug|x64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Release|ARM64.ActiveCfg = Release|ARM64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Release|ARM64.Build.0 = Release|ARM64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Release|x64.ActiveCfg = Release|x64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Release|x64.Build.0 = Release|x64 {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|ARM64.ActiveCfg = Debug|ARM64 {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|x64.ActiveCfg = Debug|x64 {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|x64.Build.0 = Debug|x64 @@ -61,14 +40,6 @@ Global {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|ARM64.Build.0 = Release|ARM64 {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|x64.ActiveCfg = Release|x64 {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|x64.Build.0 = Release|x64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Debug|ARM64.Build.0 = Debug|ARM64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Debug|x64.ActiveCfg = Debug|x64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Debug|x64.Build.0 = Debug|x64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Release|ARM64.ActiveCfg = Release|ARM64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Release|ARM64.Build.0 = Release|ARM64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Release|x64.ActiveCfg = Release|x64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Release|x64.Build.0 = Release|x64 {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|ARM64.ActiveCfg = Debug|ARM64 {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|ARM64.Build.0 = Debug|ARM64 {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|x64.ActiveCfg = Debug|x64 diff --git a/installer/PowerToysSetup/AdvancedPaste.wxs b/installer/PowerToysSetup/AdvancedPaste.wxs deleted file mode 100644 index a865ddbf6c..0000000000 --- a/installer/PowerToysSetup/AdvancedPaste.wxs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Awake.wxs b/installer/PowerToysSetup/Awake.wxs deleted file mode 100644 index a8f5536ff4..0000000000 --- a/installer/PowerToysSetup/Awake.wxs +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/BaseApplications.wxs b/installer/PowerToysSetup/BaseApplications.wxs deleted file mode 100644 index 134a3ee89a..0000000000 --- a/installer/PowerToysSetup/BaseApplications.wxs +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/CmdPal.wxs b/installer/PowerToysSetup/CmdPal.wxs deleted file mode 100644 index c3c5280cc5..0000000000 --- a/installer/PowerToysSetup/CmdPal.wxs +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/ColorPicker.wxs b/installer/PowerToysSetup/ColorPicker.wxs deleted file mode 100644 index 0c744a7b26..0000000000 --- a/installer/PowerToysSetup/ColorPicker.wxs +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Common.wxi b/installer/PowerToysSetup/Common.wxi deleted file mode 100644 index 21855a7936..0000000000 --- a/installer/PowerToysSetup/Common.wxi +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Core.wxs b/installer/PowerToysSetup/Core.wxs deleted file mode 100644 index eb39fdc9db..0000000000 --- a/installer/PowerToysSetup/Core.wxs +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - INSTALLDESKTOPSHORTCUT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/CustomDialogs/PTInstallDirDlg.wxs b/installer/PowerToysSetup/CustomDialogs/PTInstallDirDlg.wxs deleted file mode 100644 index d5697ec631..0000000000 --- a/installer/PowerToysSetup/CustomDialogs/PTInstallDirDlg.wxs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/CustomDialogs/PTLicenseDlg.wxs b/installer/PowerToysSetup/CustomDialogs/PTLicenseDlg.wxs deleted file mode 100644 index ee7b752591..0000000000 --- a/installer/PowerToysSetup/CustomDialogs/PTLicenseDlg.wxs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - 1 - - - - !(wix.WixUICostingPopupOptOut) OR CostingComplete = 1 - - - 1 - - - - - - - - \ No newline at end of file diff --git a/installer/PowerToysSetup/CustomDialogs/WixUI_PTInstallDir.wxs b/installer/PowerToysSetup/CustomDialogs/WixUI_PTInstallDir.wxs deleted file mode 100644 index a06d1ed278..0000000000 --- a/installer/PowerToysSetup/CustomDialogs/WixUI_PTInstallDir.wxs +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - "1"]]> - - 1 - - NOT Installed - Installed AND PATCH - - 1 - 1 - - 1 - 1 - NOT WIXUI_DONTVALIDATEPATH - "1"]]> - WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1" - 1 - 1 - NOT Installed - Installed AND NOT PATCH - Installed AND PATCH - - 1 - - 1 - 1 - 1 - - - - - diff --git a/installer/PowerToysSetup/EnvironmentVariables.wxs b/installer/PowerToysSetup/EnvironmentVariables.wxs deleted file mode 100644 index 44567055af..0000000000 --- a/installer/PowerToysSetup/EnvironmentVariables.wxs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/FileExplorerPreview.wxs b/installer/PowerToysSetup/FileExplorerPreview.wxs deleted file mode 100644 index 0a92d94c3e..0000000000 --- a/installer/PowerToysSetup/FileExplorerPreview.wxs +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/FileLocksmith.wxs b/installer/PowerToysSetup/FileLocksmith.wxs deleted file mode 100644 index 5943ab4147..0000000000 --- a/installer/PowerToysSetup/FileLocksmith.wxs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Hosts.wxs b/installer/PowerToysSetup/Hosts.wxs deleted file mode 100644 index cb86aa8e11..0000000000 --- a/installer/PowerToysSetup/Hosts.wxs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/ImageResizer.wxs b/installer/PowerToysSetup/ImageResizer.wxs deleted file mode 100644 index 67b5acf198..0000000000 --- a/installer/PowerToysSetup/ImageResizer.wxs +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Images/banner.png b/installer/PowerToysSetup/Images/banner.png deleted file mode 100644 index 25f878ee82..0000000000 Binary files a/installer/PowerToysSetup/Images/banner.png and /dev/null differ diff --git a/installer/PowerToysSetup/Images/dialog.png b/installer/PowerToysSetup/Images/dialog.png deleted file mode 100644 index b422990982..0000000000 Binary files a/installer/PowerToysSetup/Images/dialog.png and /dev/null differ diff --git a/installer/PowerToysSetup/Images/logo.png b/installer/PowerToysSetup/Images/logo.png deleted file mode 100644 index c9f9405405..0000000000 Binary files a/installer/PowerToysSetup/Images/logo.png and /dev/null differ diff --git a/installer/PowerToysSetup/Images/logo150.png b/installer/PowerToysSetup/Images/logo150.png deleted file mode 100644 index 35e757d8e5..0000000000 Binary files a/installer/PowerToysSetup/Images/logo150.png and /dev/null differ diff --git a/installer/PowerToysSetup/Images/logo44.png b/installer/PowerToysSetup/Images/logo44.png deleted file mode 100644 index b931931dff..0000000000 Binary files a/installer/PowerToysSetup/Images/logo44.png and /dev/null differ diff --git a/installer/PowerToysSetup/KeyboardManager.wxs b/installer/PowerToysSetup/KeyboardManager.wxs deleted file mode 100644 index dc216ccde3..0000000000 --- a/installer/PowerToysSetup/KeyboardManager.wxs +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/MouseWithoutBorders.wxs b/installer/PowerToysSetup/MouseWithoutBorders.wxs deleted file mode 100644 index 8a5efa1f8d..0000000000 --- a/installer/PowerToysSetup/MouseWithoutBorders.wxs +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/NewPlus.wxs b/installer/PowerToysSetup/NewPlus.wxs deleted file mode 100644 index 624c01fca2..0000000000 --- a/installer/PowerToysSetup/NewPlus.wxs +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Peek.wxs b/installer/PowerToysSetup/Peek.wxs deleted file mode 100644 index f87794e945..0000000000 --- a/installer/PowerToysSetup/Peek.wxs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/PowerRename.wxs b/installer/PowerToysSetup/PowerRename.wxs deleted file mode 100644 index 7aa357e207..0000000000 --- a/installer/PowerToysSetup/PowerRename.wxs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/PowerToys.wxs b/installer/PowerToysSetup/PowerToys.wxs deleted file mode 100644 index 2e50d278fb..0000000000 --- a/installer/PowerToysSetup/PowerToys.wxs +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - MinimumVersion >= DetectedPowerToysVersion - TargetPowerToysVersion >= DetectedPowerToysUserVersion OR WixBundleInstalled - - MinimumVersion >= DetectedPowerToysUserVersion - TargetPowerToysVersion >= DetectedPowerToysVersion OR WixBundleInstalled - - - - - DetectedWindowsBuildNumber >= 19041 OR WixBundleInstalled - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/PowerToysBootstrapper.wixproj b/installer/PowerToysSetup/PowerToysBootstrapper.wixproj deleted file mode 100644 index b2f5945dc2..0000000000 --- a/installer/PowerToysSetup/PowerToysBootstrapper.wixproj +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - Version=$(Version) - PowerToysBootstrapper - {31d72625-43c1-41b1-b784-bce4a8dc5543} - - - $(DefineConstants);PerUser=true - - - $(DefineConstants);PerUser=false - - - $(DefineConstants);CIBuild=true - - - $(DefineConstants);CIBuild=false - - - Release - x64 - arm64 - 3.10 - 2.0 - PowerToysSetup-$(Version)-$(Platform) - Bundle - True - PowerToysSetup-$(Version)-$(Platform) - PowerToysUserSetup-$(Version)-$(Platform) - $(Platform)\$(Configuration)\MachineSetup - $(Platform)\$(Configuration)\UserSetup - obj\$(Platform)\$(Configuration)\ - - - - - - - - $(WixExtDir)\WixUtilExtension.dll - WixUtilExtension - - - $(WixExtDir)\WixUIExtension.dll - WixUIExtension - - - $(WixExtDir)\WixNetFxExtension.dll - WixNetFxExtension - - - $(WixExtDir)\WixBalExtension.dll - WixBalExtension - - - - - - - - - - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - - - <_ValidProjectsForRestore Include="$(MSBuildProjectFullPath)" /> - - - - \ No newline at end of file diff --git a/installer/PowerToysSetup/PowerToysInstaller.wixproj b/installer/PowerToysSetup/PowerToysInstaller.wixproj deleted file mode 100644 index 6a28fbc896..0000000000 --- a/installer/PowerToysSetup/PowerToysInstaller.wixproj +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - - Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\x64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion) - - IF NOT DEFINED IsPipeline ( -call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=amd64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) -SET PTRoot=$(SolutionDir)\.. -call "..\..\..\publish.cmd" x64 -) -call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" - - - - Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion); - IF NOT DEFINED IsPipeline ( -call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=arm64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) -SET PTRoot=$(SolutionDir)\.. -call "..\..\..\publish.cmd" arm64 -) -call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" - - - - Always - - call move /Y ..\..\..\AdvancedPaste.wxs.bk ..\..\..\AdvancedPaste.wxs - call move /Y ..\..\..\Awake.wxs.bk ..\..\..\Awake.wxs - call move /Y ..\..\..\BaseApplications.wxs.bk ..\..\..\BaseApplications.wxs - call move /Y ..\..\..\CmdPal.wxs.bk ..\..\..\CmdPal.wxs - call move /Y ..\..\..\ColorPicker.wxs.bk ..\..\..\ColorPicker.wxs - call move /Y ..\..\..\Core.wxs.bk ..\..\..\Core.wxs - call move /Y ..\..\..\EnvironmentVariables.wxs.bk ..\..\..\EnvironmentVariables.wxs - call move /Y ..\..\..\FileExplorerPreview.wxs.bk ..\..\..\FileExplorerPreview.wxs - call move /Y ..\..\..\FileLocksmith.wxs.bk ..\..\..\FileLocksmith.wxs - call move /Y ..\..\..\Hosts.wxs.bk ..\..\..\Hosts.wxs - call move /Y ..\..\..\ImageResizer.wxs.bk ..\..\..\ImageResizer.wxs - call move /Y ..\..\..\KeyboardManager.wxs.bk ..\..\..\KeyboardManager.wxs - call move /Y ..\..\..\MouseWithoutBorders.wxs.bk ..\..\..\MouseWithoutBorders.wxs - call move /Y ..\..\..\NewPlus.wxs.bk ..\..\..\NewPlus.wxs - call move /Y ..\..\..\Peek.wxs.bk ..\..\..\Peek.wxs - call move /Y ..\..\..\PowerRename.wxs.bk ..\..\..\PowerRename.wxs - call move /Y ..\..\..\Product.wxs.bk ..\..\..\Product.wxs - call move /Y ..\..\..\RegistryPreview.wxs.bk ..\..\..\RegistryPreview.wxs - call move /Y ..\..\..\Resources.wxs.bk ..\..\..\Resources.wxs - call move /Y ..\..\..\Run.wxs.bk ..\..\..\Run.wxs - call move /Y ..\..\..\Settings.wxs.bk ..\..\..\Settings.wxs - call move /Y ..\..\..\ShortcutGuide.wxs.bk ..\..\..\ShortcutGuide.wxs - call move /Y ..\..\..\Tools.wxs.bk ..\..\..\Tools.wxs - call move /Y ..\..\..\WinAppSDK.wxs.bk ..\..\..\WinAppSDK.wxs - call move /Y ..\..\..\WinUI3Applications.wxs.bk ..\..\..\WinUI3Applications.wxs - call move /Y ..\..\..\Workspaces.wxs.bk ..\..\..\Workspaces.wxs - - - - PowerToysInstaller - - - $(DefineConstants);PerUser=true - - - $(DefineConstants);PerUser=false - - - $(DefineConstants);CIBuild=true - - - $(DefineConstants);CIBuild=false - - - - Release - $(Platform) - 3.10 - 022a9d30-7c4f-416d-a9df-5ff2661cc0ad - 2.0 - PowerToysSetup-$(Version)-$(Platform) - PowerToysUserSetup-$(Version)-$(Platform) - Package - True - - - - - ICE91 - 1026;1076 - - - $(Platform)\$(Configuration)\MachineSetup - $(Platform)\$(Configuration)\UserSetup - obj\$(Platform)\$(Configuration)\MachineSetup - obj\$(Platform)\$(Configuration)\UserSetup - ICE40 - - - - - -v -sh -sw1108 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - $(WixExtDir)\WixFirewallExtension.dll - WixFirewallExtension - - - $(WixExtDir)\WixUtilExtension.dll - WixUtilExtension - - - $(WixExtDir)\WixUIExtension.dll - WixUIExtension - - - $(WixExtDir)\WixNetFxExtension.dll - WixNetFxExtension - - - - - - - - PowerToysSetupCustomActions - {32f3882b-f2d6-4586-b5ed-11e39e522bd3} - True - True - Binaries;Content;Satellites - INSTALLFOLDER - - - - - - - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - - - - - - <_ValidProjectsForRestore Include="$(MSBuildProjectFullPath)" /> - - - - diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs deleted file mode 100644 index 77ffad8483..0000000000 --- a/installer/PowerToysSetup/Product.wxs +++ /dev/null @@ -1,540 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - = 19041)]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - 1 - - NOT Installed - 1 - NOT Installed - NOT Installed - Installed AND _REMOVE_ALL="Yes" - Installed AND _REMOVE_ALL="Yes" - Installed AND NOT (_REMOVE_ALL="Yes") - Installed AND NOT (_REMOVE_ALL="Yes") - - - - - - - - - - - - - - - - - ""]]> - - - DEFAULTBOOTSTRAPPERINSTALLFOLDER OR PREVIOUSINSTALLFOLDER = ""]]> - - - - - - - - - - - - - - - - - - NOT Installed - - - NOT Installed - - - NOT Installed - - - - - - - - - - NOT Installed - - - Installed and (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - - - Installed AND (REMOVE="ALL") - - - Installed AND (REMOVE="ALL") - - - Installed AND (REMOVE="ALL") - - - Installed AND (REMOVE="ALL") - - - Installed AND (REMOVE="ALL") - - - Installed AND (REMOVE="ALL") - - - Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - - - Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - - - Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - - - WIX_UPGRADE_DETECTED - - - Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - - - - - - Installed AND (REMOVE="ALL") - - - - NOT Installed - - - NOT Installed - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/RegistryPreview.wxs b/installer/PowerToysSetup/RegistryPreview.wxs deleted file mode 100644 index f7bd3948d4..0000000000 --- a/installer/PowerToysSetup/RegistryPreview.wxs +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Resources.wxs b/installer/PowerToysSetup/Resources.wxs deleted file mode 100644 index b238799dd1..0000000000 --- a/installer/PowerToysSetup/Resources.wxs +++ /dev/null @@ -1,564 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Run.wxs b/installer/PowerToysSetup/Run.wxs deleted file mode 100644 index 94a589585f..0000000000 --- a/installer/PowerToysSetup/Run.wxs +++ /dev/null @@ -1,448 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Settings.wxs b/installer/PowerToysSetup/Settings.wxs deleted file mode 100644 index 07a2b056dc..0000000000 --- a/installer/PowerToysSetup/Settings.wxs +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/ShortcutGuide.wxs b/installer/PowerToysSetup/ShortcutGuide.wxs deleted file mode 100644 index 729a805861..0000000000 --- a/installer/PowerToysSetup/ShortcutGuide.wxs +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Tools.wxs b/installer/PowerToysSetup/Tools.wxs deleted file mode 100644 index 24c3fc2007..0000000000 --- a/installer/PowerToysSetup/Tools.wxs +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/WebView2/MicrosoftEdgeWebview2Setup.exe b/installer/PowerToysSetup/WebView2/MicrosoftEdgeWebview2Setup.exe deleted file mode 100644 index d95ba2e893..0000000000 Binary files a/installer/PowerToysSetup/WebView2/MicrosoftEdgeWebview2Setup.exe and /dev/null differ diff --git a/installer/PowerToysSetup/WinAppSDK.wxs b/installer/PowerToysSetup/WinAppSDK.wxs deleted file mode 100644 index 631fb033ef..0000000000 --- a/installer/PowerToysSetup/WinAppSDK.wxs +++ /dev/null @@ -1,466 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/WinUI3Applications.wxs b/installer/PowerToysSetup/WinUI3Applications.wxs deleted file mode 100644 index 742f3dcf80..0000000000 --- a/installer/PowerToysSetup/WinUI3Applications.wxs +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/Workspaces.wxs b/installer/PowerToysSetup/Workspaces.wxs deleted file mode 100644 index 4237aab945..0000000000 --- a/installer/PowerToysSetup/Workspaces.wxs +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/installer/PowerToysSetup/generateAllFileComponents.ps1 b/installer/PowerToysSetup/generateAllFileComponents.ps1 deleted file mode 100644 index a8365d8649..0000000000 --- a/installer/PowerToysSetup/generateAllFileComponents.ps1 +++ /dev/null @@ -1,323 +0,0 @@ -[CmdletBinding()] -Param( - [Parameter(Mandatory = $True, Position = 1)] - [string]$platform, - [Parameter(Mandatory = $False, Position = 2)] - [string]$installscopeperuser = "false" -) - -Function Generate-FileList() { - [CmdletBinding()] - Param( - # Can be multiple files separated by ; as long as they're on the same directory - [Parameter(Mandatory = $True, Position = 1)] - [AllowEmptyString()] - [string]$fileDepsJson, - [Parameter(Mandatory = $True, Position = 2)] - [string]$fileListName, - [Parameter(Mandatory = $True, Position = 3)] - [string]$wxsFilePath, - # If there is no deps.json file, just pass path to files - [Parameter(Mandatory = $False, Position = 4)] - [string]$depsPath, - # launcher plugins are being loaded into launcher process, - # so there are some additional dependencies to skip - [Parameter(Mandatory = $False, Position = 5)] - [bool]$isLauncherPlugin - ) - - $fileWxs = Get-Content $wxsFilePath; - - $fileExclusionList = @("*.pdb", "*.lastcodeanalysissucceeded", "createdump.exe", "powertoys.exe") - - $fileInclusionList = @("*.dll", "*.exe", "*.json", "*.msix", "*.png", "*.gif", "*.ico", "*.cur", "*.svg", "index.html", "reg.js", "gitignore.js", "srt.js", "monacoSpecialLanguages.js", "customTokenThemeRules.js", "*.pri") - - $dllsToIgnore = @("System.CodeDom.dll", "WindowsBase.dll") - - if ($fileDepsJson -eq [string]::Empty) { - $fileDepsRoot = $depsPath - } else { - $multipleDepsJson = $fileDepsJson.Split(";") - - foreach ( $singleDepsJson in $multipleDepsJson ) - { - - $fileDepsRoot = (Get-ChildItem $singleDepsJson).Directory.FullName - $depsJson = Get-Content $singleDepsJson | ConvertFrom-Json - - $runtimeList = ([array]$depsJson.targets.PSObject.Properties)[-1].Value.PSObject.Properties | Where-Object { - $_.Name -match "runtimepack.*Runtime" - }; - - $runtimeList | ForEach-Object { - $_.Value.PSObject.Properties.Value | ForEach-Object { - $fileExclusionList += $_.PSObject.Properties.Name - } - } - } - } - - $fileExclusionList = $fileExclusionList | Where-Object {$_ -notin $dllsToIgnore} - - if ($isLauncherPlugin -eq $True) { - $fileInclusionList += @("*.deps.json") - $fileExclusionList += @("Ijwhost.dll", "PowerToys.Common.UI.dll", "PowerToys.GPOWrapper.dll", "PowerToys.GPOWrapperProjection.dll", "PowerToys.PowerLauncher.Telemetry.dll", "PowerToys.ManagedCommon.dll", "PowerToys.Settings.UI.Lib.dll", "Wox.Infrastructure.dll", "Wox.Plugin.dll") - } - - $fileList = Get-ChildItem $fileDepsRoot -Include $fileInclusionList -Exclude $fileExclusionList -File -Name - - $fileWxs = $fileWxs -replace "(<\?define $($fileListName)=)", "") { - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'fileList', - Justification = 'variable is used in another scope')] - - $fileList = $matches[2] -split ';' - return - } - } - - $componentId = "$($fileListName)_Component" - - $componentDefs = "`r`n" - $componentDefs += - @" - - - - `r`n -"@ - - foreach ($file in $fileList) { - $fileTmp = $file -replace "-", "_" - $componentDefs += - @" - `r`n -"@ - } - - $componentDefs += - @" - `r`n -"@ - - $wxsFile = $wxsFile -replace "\s+()", $componentDefs - - $componentRef = - @" - -"@ - - $wxsFile = $wxsFile -replace "\s+()", "$componentRef`r`n " - - Set-Content -Path $wxsFilePath -Value $wxsFile -} - -if ($platform -ceq "arm64") { - $platform = "ARM64" -} - -if ($installscopeperuser -eq "true") { - $registryroot = "HKCU" -} else { - $registryroot = "HKLM" -} - -#BaseApplications -Generate-FileList -fileDepsJson "" -fileListName BaseApplicationsFiles -wxsFilePath $PSScriptRoot\BaseApplications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release" -Generate-FileComponents -fileListName "BaseApplicationsFiles" -wxsFilePath $PSScriptRoot\BaseApplications.wxs -regroot $registryroot - -#WinUI3Applications -Generate-FileList -fileDepsJson "" -fileListName WinUI3ApplicationsFiles -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps" -Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -regroot $registryroot - -#AdvancedPaste -Generate-FileList -fileDepsJson "" -fileListName AdvancedPasteAssetsFiles -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\AdvancedPaste" -Generate-FileComponents -fileListName "AdvancedPasteAssetsFiles" -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs -regroot $registryroot - -#AwakeFiles -Generate-FileList -fileDepsJson "" -fileListName AwakeImagesFiles -wxsFilePath $PSScriptRoot\Awake.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Awake" -Generate-FileComponents -fileListName "AwakeImagesFiles" -wxsFilePath $PSScriptRoot\Awake.wxs -regroot $registryroot - -#ColorPicker -Generate-FileList -fileDepsJson "" -fileListName ColorPickerAssetsFiles -wxsFilePath $PSScriptRoot\ColorPicker.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\ColorPicker" -Generate-FileComponents -fileListName "ColorPickerAssetsFiles" -wxsFilePath $PSScriptRoot\ColorPicker.wxs -regroot $registryroot - -#Environment Variables -Generate-FileList -fileDepsJson "" -fileListName EnvironmentVariablesAssetsFiles -wxsFilePath $PSScriptRoot\EnvironmentVariables.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\EnvironmentVariables" -Generate-FileComponents -fileListName "EnvironmentVariablesAssetsFiles" -wxsFilePath $PSScriptRoot\EnvironmentVariables.wxs -regroot $registryroot - -#FileExplorerAdd-ons -Generate-FileList -fileDepsJson "" -fileListName MonacoPreviewHandlerMonacoAssetsFiles -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Monaco" -Generate-FileList -fileDepsJson "" -fileListName MonacoPreviewHandlerCustomLanguagesFiles -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Monaco\customLanguages" -Generate-FileComponents -fileListName "MonacoPreviewHandlerMonacoAssetsFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -regroot $registryroot -Generate-FileComponents -fileListName "MonacoPreviewHandlerCustomLanguagesFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -regroot $registryroot - -#FileLocksmith -Generate-FileList -fileDepsJson "" -fileListName FileLocksmithAssetsFiles -wxsFilePath $PSScriptRoot\FileLocksmith.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\FileLocksmith" -Generate-FileComponents -fileListName "FileLocksmithAssetsFiles" -wxsFilePath $PSScriptRoot\FileLocksmith.wxs -regroot $registryroot - -#Hosts -Generate-FileList -fileDepsJson "" -fileListName HostsAssetsFiles -wxsFilePath $PSScriptRoot\Hosts.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Hosts" -Generate-FileComponents -fileListName "HostsAssetsFiles" -wxsFilePath $PSScriptRoot\Hosts.wxs -regroot $registryroot - -#ImageResizer -Generate-FileList -fileDepsJson "" -fileListName ImageResizerAssetsFiles -wxsFilePath $PSScriptRoot\ImageResizer.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\ImageResizer" -Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PSScriptRoot\ImageResizer.wxs -regroot $registryroot - -#New+ -Generate-FileList -fileDepsJson "" -fileListName NewPlusAssetsFiles -wxsFilePath $PSScriptRoot\NewPlus.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\NewPlus" -Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs -regroot $registryroot - -#Peek -Generate-FileList -fileDepsJson "" -fileListName PeekAssetsFiles -wxsFilePath $PSScriptRoot\Peek.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Peek\" -Generate-FileComponents -fileListName "PeekAssetsFiles" -wxsFilePath $PSScriptRoot\Peek.wxs -regroot $registryroot - -#PowerRename -Generate-FileList -fileDepsJson "" -fileListName PowerRenameAssetsFiles -wxsFilePath $PSScriptRoot\PowerRename.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerRename\" -Generate-FileComponents -fileListName "PowerRenameAssetsFiles" -wxsFilePath $PSScriptRoot\PowerRename.wxs -regroot $registryroot - -#RegistryPreview -Generate-FileList -fileDepsJson "" -fileListName RegistryPreviewAssetsFiles -wxsFilePath $PSScriptRoot\RegistryPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\RegistryPreview\" -Generate-FileComponents -fileListName "RegistryPreviewAssetsFiles" -wxsFilePath $PSScriptRoot\RegistryPreview.wxs -regroot $registryroot - -#Run -Generate-FileList -fileDepsJson "" -fileListName launcherImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\PowerLauncher" -Generate-FileComponents -fileListName "launcherImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -## Plugins -###Calculator -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Calculator\Microsoft.PowerToys.Run.Plugin.Calculator.deps.json" -fileListName calcComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName calcImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Calculator\Images" -Generate-FileComponents -fileListName "calcComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "calcImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###Folder -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Folder\Microsoft.Plugin.Folder.deps.json" -fileListName FolderComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName FolderImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Folder\Images" -Generate-FileComponents -fileListName "FolderComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "FolderImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###Program -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Program\Microsoft.Plugin.Program.deps.json" -fileListName ProgramComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName ProgramImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Program\Images" -Generate-FileComponents -fileListName "ProgramComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ProgramImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###Shell -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Shell\Microsoft.Plugin.Shell.deps.json" -fileListName ShellComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName ShellImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Shell\Images" -Generate-FileComponents -fileListName "ShellComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ShellImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###Indexer -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Indexer\Microsoft.Plugin.Indexer.deps.json" -fileListName IndexerComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName IndexerImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Indexer\Images" -Generate-FileComponents -fileListName "IndexerComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "IndexerImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###UnitConverter -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\UnitConverter\Community.PowerToys.Run.Plugin.UnitConverter.deps.json" -fileListName UnitConvCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName UnitConvImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\UnitConverter\Images" -Generate-FileComponents -fileListName "UnitConvCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "UnitConvImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###WebSearch -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WebSearch\Community.PowerToys.Run.Plugin.WebSearch.deps.json" -fileListName WebSrchCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName WebSrchImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WebSearch\Images" -Generate-FileComponents -fileListName "WebSrchCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WebSrchImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###History -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\History\Microsoft.PowerToys.Run.Plugin.History.deps.json" -fileListName HistoryPluginComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName HistoryPluginImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\History\Images" -Generate-FileComponents -fileListName "HistoryPluginComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "HistoryPluginImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###Uri -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Uri\Microsoft.Plugin.Uri.deps.json" -fileListName UriComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName UriImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Uri\Images" -Generate-FileComponents -fileListName "UriComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "UriImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###VSCodeWorkspaces -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\VSCodeWorkspaces\Community.PowerToys.Run.Plugin.VSCodeWorkspaces.deps.json" -fileListName VSCWrkCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName VSCWrkImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\VSCodeWorkspaces\Images" -Generate-FileComponents -fileListName "VSCWrkCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "VSCWrkImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###WindowWalker -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowWalker\Microsoft.Plugin.WindowWalker.deps.json" -fileListName WindowWlkrCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName WindowWlkrImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowWalker\Images" -Generate-FileComponents -fileListName "WindowWlkrCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WindowWlkrImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###OneNote -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\OneNote\Microsoft.PowerToys.Run.Plugin.OneNote.deps.json" -fileListName OneNoteComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName OneNoteImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\OneNote\Images" -Generate-FileComponents -fileListName "OneNoteComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "OneNoteImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###Registry -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Registry\Microsoft.PowerToys.Run.Plugin.Registry.deps.json" -fileListName RegistryComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName RegistryImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Registry\Images" -Generate-FileComponents -fileListName "RegistryComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "RegistryImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###Service -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Service\Microsoft.PowerToys.Run.Plugin.Service.deps.json" -fileListName ServiceComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName ServiceImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Service\Images" -Generate-FileComponents -fileListName "ServiceComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ServiceImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###System -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\System\Microsoft.PowerToys.Run.Plugin.System.deps.json" -fileListName SystemComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName SystemImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\System\Images" -Generate-FileComponents -fileListName "SystemComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SystemImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###TimeDate -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\TimeDate\Microsoft.PowerToys.Run.Plugin.TimeDate.deps.json" -fileListName TimeDateComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName TimeDateImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\TimeDate\Images" -Generate-FileComponents -fileListName "TimeDateComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "TimeDateImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###WindowsSettings -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsSettings\Microsoft.PowerToys.Run.Plugin.WindowsSettings.deps.json" -fileListName WinSetCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName WinSetImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsSettings\Images" -Generate-FileComponents -fileListName "WinSetCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WinSetImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###WindowsTerminal -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsTerminal\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.deps.json" -fileListName WinTermCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName WinTermImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsTerminal\Images" -Generate-FileComponents -fileListName "WinTermCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WinTermImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###PowerToys -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\PowerToys\Microsoft.PowerToys.Run.Plugin.PowerToys.deps.json" -fileListName PowerToysCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName PowerToysImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\PowerToys\Images" -Generate-FileComponents -fileListName "PowerToysCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "PowerToysImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -###ValueGenerator -Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\ValueGenerator\Community.PowerToys.Run.Plugin.ValueGenerator.deps.json" -fileListName ValueGeneratorCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 -Generate-FileList -fileDepsJson "" -fileListName ValueGeneratorImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\ValueGenerator\Images" -Generate-FileComponents -fileListName "ValueGeneratorCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ValueGeneratorImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -## Plugins - -#ShortcutGuide -Generate-FileList -fileDepsJson "" -fileListName ShortcutGuideSvgFiles -wxsFilePath $PSScriptRoot\ShortcutGuide.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\ShortcutGuide\" -Generate-FileComponents -fileListName "ShortcutGuideSvgFiles" -wxsFilePath $PSScriptRoot\ShortcutGuide.wxs -regroot $registryroot - -#Settings -Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\" -Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\" -Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\OOBE\" -Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsFluentIconsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\" -Generate-FileComponents -fileListName "SettingsV2AssetsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2AssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2OOBEAssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2OOBEAssetsFluentIconsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot - -#Workspaces -Generate-FileList -fileDepsJson "" -fileListName WorkspacesImagesComponentFiles -wxsFilePath $PSScriptRoot\Workspaces.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Workspaces\" -Generate-FileComponents -fileListName "WorkspacesImagesComponentFiles" -wxsFilePath $PSScriptRoot\Workspaces.wxs -regroot $registryroot diff --git a/installer/PowerToysSetup/generateMonacoWxs.ps1 b/installer/PowerToysSetup/generateMonacoWxs.ps1 deleted file mode 100644 index 94536618da..0000000000 --- a/installer/PowerToysSetup/generateMonacoWxs.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -[CmdletBinding()] -Param( - [Parameter(Mandatory = $True, Position = 1)] - [string]$monacoWxsFile -) - -$fileWxs = Get-Content $monacoWxsFile; - -$fileWxs = $fileWxs -replace " KeyPath=`"yes`" ", " " - -$newFileContent = "" - -$componentId = "error" -$directories = @() - -$fileWxs | ForEach-Object { - $line = $_; - if ($line -match "") { - $line += -@" -`r`n - `r`n -"@ - } - if ($line -match "") { - $directories += $matches[1] - } - if ($line -match "") { - $line = -@" - - - - -"@ - } - - $newFileContent += $line + "`r`n"; -} - -$removeFolderEntries = -@" -`r`n - - - `r`n -"@ - -$directories | ForEach-Object { - - $removeFolderEntries += -@" - - -"@ -} - -$removeFolderEntries += -@" - -"@ - - - -$newFileContent = $newFileContent -replace "\s+()", "$removeFolderEntries`r`n " - -Set-Content -Path $monacoWxsFile -Value $newFileContent \ No newline at end of file diff --git a/installer/PowerToysSetup/packages.config b/installer/PowerToysSetup/packages.config deleted file mode 100644 index 569e1bea86..0000000000 --- a/installer/PowerToysSetup/packages.config +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/installer/PowerToysSetup/publish.cmd b/installer/PowerToysSetup/publish.cmd deleted file mode 100644 index f61668cffe..0000000000 --- a/installer/PowerToysSetup/publish.cmd +++ /dev/null @@ -1,17 +0,0 @@ -setlocal enableDelayedExpansion - -IF NOT DEFINED PTRoot (SET PTRoot=..\..) - -SET PlatformArg=%1 -IF NOT DEFINED PlatformArg (SET PlatformArg=x64) -SET VCToolsVersion=!VCToolsVersion! -SET ClearDevCommandPromptEnvVars=false - -rem In case of Release we should not use Debug CRT in VCRT forwarders -msbuild !PTRoot!\src\modules\previewpane\MonacoPreviewHandler\MonacoPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml -p:TargetFramework=net9.0-windows10.0.26100.0 - -msbuild !PTRoot!\src\modules\previewpane\MarkdownPreviewHandler\MarkdownPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml -p:TargetFramework=net9.0-windows10.0.26100.0 - -msbuild !PTRoot!\src\modules\previewpane\SvgPreviewHandler\SvgPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml -p:TargetFramework=net9.0-windows10.0.26100.0 - -msbuild !PTRoot!\src\modules\previewpane\SvgThumbnailProvider\SvgThumbnailProvider.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml -p:TargetFramework=net9.0-windows10.0.26100.0 \ No newline at end of file diff --git a/installer/PowerToysSetup/terminate_powertoys.cmd b/installer/PowerToysSetup/terminate_powertoys.cmd deleted file mode 100644 index 8206b90aae..0000000000 --- a/installer/PowerToysSetup/terminate_powertoys.cmd +++ /dev/null @@ -1,12 +0,0 @@ -@echo off -setlocal ENABLEDELAYEDEXPANSION - -@REM We loop here until taskkill cannot find a PowerToys process. We can't use /F flag, because it -@REM doesn't give application an opportunity to cleanup. Thus we send WM_CLOSE which is being caught -@REM by multiple windows running a msg loop in PowerToys.exe process, which we close one by one. -for /l %%x in (1, 1, 100) do ( - taskkill /IM PowerToys.exe 1>NUL 2>NUL - if !ERRORLEVEL! NEQ 0 goto quit -) - -:quit \ No newline at end of file diff --git a/installer/PowerToysSetupCustomActions/CustomAction.cpp b/installer/PowerToysSetupCustomActions/CustomAction.cpp deleted file mode 100644 index 74304c4163..0000000000 --- a/installer/PowerToysSetupCustomActions/CustomAction.cpp +++ /dev/null @@ -1,1410 +0,0 @@ -#include "pch.h" -#include "resource.h" -#include "RcResource.h" -#include -#include - -#include "../../src/common/logger/logger.h" -#include "../../src/common/utils/gpo.h" -#include "../../src/common/utils/MsiUtils.h" -#include "../../src/common/utils/modulesRegistry.h" -#include "../../src/common/updating/installer.h" -#include "../../src/common/version/version.h" -#include "../../src/common/Telemetry/EtwTrace/EtwTrace.h" -#include "../../src/common/utils/package.h" -#include "../../src/common/utils/clean_video_conference.h" - -#include -#include -#include -#include - -#include -#include -#include -#include - -using namespace std; - -HINSTANCE DLL_HANDLE = nullptr; - -TRACELOGGING_DEFINE_PROVIDER( - g_hProvider, - "Microsoft.PowerToys", - // {38e8889b-9731-53f5-e901-e8a7c1753074} - (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), - TraceLoggingOptionProjectTelemetry()); - -const DWORD USERNAME_DOMAIN_LEN = DNLEN + UNLEN + 2; // Domain Name + '\' + User Name + '\0' -const DWORD USERNAME_LEN = UNLEN + 1; // User Name + '\0' - -static const wchar_t *POWERTOYS_EXE_COMPONENT = L"{A2C66D91-3485-4D00-B04D-91844E6B345B}"; -static const wchar_t *POWERTOYS_UPGRADE_CODE = L"{42B84BF7-5FBF-473B-9C8B-049DC16F7708}"; - -constexpr inline const wchar_t *DataDiagnosticsRegKey = L"Software\\Classes\\PowerToys"; -constexpr inline const wchar_t *DataDiagnosticsRegValueName = L"AllowDataDiagnostics"; - -#define TraceLoggingWriteWrapper(provider, eventName, ...) \ - if (isDataDiagnosticEnabled()) \ - { \ - trace.UpdateState(true); \ - TraceLoggingWrite(provider, eventName, __VA_ARGS__); \ - trace.Flush(); \ - trace.UpdateState(false); \ - } - -static Shared::Trace::ETWTrace trace{L"PowerToys_Installer"}; - -inline bool isDataDiagnosticEnabled() -{ - HKEY key{}; - if (RegOpenKeyExW(HKEY_CURRENT_USER, - DataDiagnosticsRegKey, - 0, - KEY_READ, - &key) != ERROR_SUCCESS) - { - return false; - } - - DWORD isDataDiagnosticsEnabled = 0; - DWORD size = sizeof(isDataDiagnosticsEnabled); - - if (RegGetValueW( - HKEY_CURRENT_USER, - DataDiagnosticsRegKey, - DataDiagnosticsRegValueName, - RRF_RT_REG_DWORD, - nullptr, - &isDataDiagnosticsEnabled, - &size) != ERROR_SUCCESS) - { - RegCloseKey(key); - return false; - } - RegCloseKey(key); - - return isDataDiagnosticsEnabled == 1; -} - -HRESULT getInstallFolder(MSIHANDLE hInstall, std::wstring &installationDir) -{ - DWORD len = 0; - wchar_t _[1]; - MsiGetPropertyW(hInstall, L"CustomActionData", _, &len); - len += 1; - installationDir.resize(len); - HRESULT hr = MsiGetPropertyW(hInstall, L"CustomActionData", installationDir.data(), &len); - if (installationDir.length()) - { - installationDir.resize(installationDir.length() - 1); - } - ExitOnFailure(hr, "Failed to get INSTALLFOLDER property."); -LExit: - return hr; -} - -BOOL IsLocalSystem() -{ - HANDLE hToken; - UCHAR bTokenUser[sizeof(TOKEN_USER) + 8 + 4 * SID_MAX_SUB_AUTHORITIES]; - PTOKEN_USER pTokenUser = (PTOKEN_USER)bTokenUser; - ULONG cbTokenUser; - SID_IDENTIFIER_AUTHORITY siaNT = SECURITY_NT_AUTHORITY; - PSID pSystemSid; - BOOL bSystem; - - // open process token - if (!OpenProcessToken(GetCurrentProcess(), - TOKEN_QUERY, - &hToken)) - return FALSE; - - // retrieve user SID - if (!GetTokenInformation(hToken, TokenUser, pTokenUser, - sizeof(bTokenUser), &cbTokenUser)) - { - CloseHandle(hToken); - return FALSE; - } - - CloseHandle(hToken); - - // allocate LocalSystem well-known SID - if (!AllocateAndInitializeSid(&siaNT, 1, SECURITY_LOCAL_SYSTEM_RID, - 0, 0, 0, 0, 0, 0, 0, &pSystemSid)) - return FALSE; - - // compare the user SID from the token with the LocalSystem SID - bSystem = EqualSid(pTokenUser->User.Sid, pSystemSid); - - FreeSid(pSystemSid); - - return bSystem; -} - -BOOL ImpersonateLoggedInUserAndDoSomething(std::function action) -{ - HRESULT hr = S_OK; - HANDLE hUserToken = NULL; - DWORD dwSessionId; - ProcessIdToSessionId(GetCurrentProcessId(), &dwSessionId); - auto rv = WTSQueryUserToken(dwSessionId, &hUserToken); - - if (rv == 0) - { - hr = E_ABORT; - ExitOnFailure(hr, "Failed to query user token"); - } - - HANDLE hUserTokenDup; - if (DuplicateTokenEx(hUserToken, TOKEN_ALL_ACCESS, NULL, SECURITY_IMPERSONATION_LEVEL::SecurityImpersonation, TOKEN_TYPE::TokenPrimary, &hUserTokenDup) == 0) - { - CloseHandle(hUserToken); - CloseHandle(hUserTokenDup); - hr = E_ABORT; - ExitOnFailure(hr, "Failed to duplicate user token"); - } - - if (ImpersonateLoggedOnUser(hUserTokenDup)) - { - if (!action(hUserTokenDup)) - { - hr = E_ABORT; - ExitOnFailure(hr, "Failed to execute action"); - } - - RevertToSelf(); - CloseHandle(hUserToken); - CloseHandle(hUserTokenDup); - } - else - { - hr = E_ABORT; - ExitOnFailure(hr, "Failed to duplicate user token"); - } - -LExit: - return SUCCEEDED(hr); -} - -static std::filesystem::path GetUserPowerShellModulesPath() -{ - PWSTR myDocumentsBlockPtr; - - if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Documents, 0, NULL, &myDocumentsBlockPtr))) - { - const std::wstring myDocuments{myDocumentsBlockPtr}; - CoTaskMemFree(myDocumentsBlockPtr); - return std::filesystem::path(myDocuments) / "PowerShell" / "Modules"; - } - else - { - CoTaskMemFree(myDocumentsBlockPtr); - return {}; - } -} - -UINT __stdcall LaunchPowerToysCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - std::wstring installationFolder, path, args; - std::wstring commandLine; - - hr = WcaInitialize(hInstall, "LaunchPowerToys"); - ExitOnFailure(hr, "Failed to initialize"); - hr = getInstallFolder(hInstall, installationFolder); - ExitOnFailure(hr, "Failed to get installFolder."); - - path = installationFolder; - path += L"\\PowerToys.exe"; - - args = L"--dont-elevate"; - - commandLine = L"\"" + path + L"\" "; - commandLine += args; - - BOOL isSystemUser = IsLocalSystem(); - - if (isSystemUser) - { - - auto action = [&commandLine](HANDLE userToken) - { - STARTUPINFO startupInfo{.cb = sizeof(STARTUPINFO), .wShowWindow = SW_SHOWNORMAL}; - PROCESS_INFORMATION processInformation; - - PVOID lpEnvironment = NULL; - CreateEnvironmentBlock(&lpEnvironment, userToken, FALSE); - - CreateProcessAsUser( - userToken, - NULL, - commandLine.data(), - NULL, - NULL, - FALSE, - CREATE_DEFAULT_ERROR_MODE | CREATE_UNICODE_ENVIRONMENT, - lpEnvironment, - NULL, - &startupInfo, - &processInformation); - - if (!CloseHandle(processInformation.hProcess)) - { - return false; - } - if (!CloseHandle(processInformation.hThread)) - { - return false; - } - - return true; - }; - - if (!ImpersonateLoggedInUserAndDoSomething(action)) - { - hr = E_ABORT; - ExitOnFailure(hr, "ImpersonateLoggedInUserAndDoSomething failed"); - } - } - else - { - STARTUPINFO startupInfo{.cb = sizeof(STARTUPINFO), .wShowWindow = SW_SHOWNORMAL}; - - PROCESS_INFORMATION processInformation; - - // Start the resizer - CreateProcess( - NULL, - commandLine.data(), - NULL, - NULL, - TRUE, - 0, - NULL, - NULL, - &startupInfo, - &processInformation); - - if (!CloseHandle(processInformation.hProcess)) - { - hr = E_ABORT; - ExitOnFailure(hr, "Failed to close process handle"); - } - if (!CloseHandle(processInformation.hThread)) - { - hr = E_ABORT; - ExitOnFailure(hr, "Failed to close thread handle"); - } - } - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall CheckGPOCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - - hr = WcaInitialize(hInstall, "CheckGPOCA"); - ExitOnFailure(hr, "Failed to initialize"); - - LPWSTR currentScope = nullptr; - hr = WcaGetProperty(L"InstallScope", ¤tScope); - - if (std::wstring{currentScope} == L"perUser") - { - if (powertoys_gpo::getDisablePerUserInstallationValue() == powertoys_gpo::gpo_rule_configured_enabled) - { - PMSIHANDLE hRecord = MsiCreateRecord(0); - MsiRecordSetString(hRecord, 0, TEXT("The system administrator has disabled per-user installation.")); - MsiProcessMessage(hInstall, static_cast(INSTALLMESSAGE_ERROR + MB_OK), hRecord); - hr = E_ABORT; - } - } - -LExit: - UINT er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -// We've deprecated Video Conference Mute. This Custom Action cleans up any stray registry entry for the driver dll. -UINT __stdcall CleanVideoConferenceRegistryCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "CleanVideoConferenceRegistry"); - ExitOnFailure(hr, "Failed to initialize"); - clean_video_conference(); -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall ApplyModulesRegistryChangeSetsCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - std::wstring installationFolder; - bool failedToApply = false; - - hr = WcaInitialize(hInstall, "ApplyModulesRegistryChangeSets"); - ExitOnFailure(hr, "Failed to initialize"); - hr = getInstallFolder(hInstall, installationFolder); - ExitOnFailure(hr, "Failed to get installFolder."); - - for (const auto &changeSet : getAllOnByDefaultModulesChangeSets(installationFolder)) - { - if (!changeSet.apply()) - { - Logger::error(L"Couldn't apply registry changeSet"); - failedToApply = true; - } - } - - if (!failedToApply) - { - Logger::info(L"All registry changeSets applied successfully"); - } -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall UnApplyModulesRegistryChangeSetsCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - std::wstring installationFolder; - - hr = WcaInitialize(hInstall, "UndoModulesRegistryChangeSets"); // original func name is too long - ExitOnFailure(hr, "Failed to initialize"); - hr = getInstallFolder(hInstall, installationFolder); - ExitOnFailure(hr, "Failed to get installFolder."); - for (const auto &changeSet : getAllModulesChangeSets(installationFolder)) - { - changeSet.unApply(); - } - - SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, NULL, NULL); - - ExitOnFailure(hr, "Failed to extract msix"); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -const wchar_t *DSC_CONFIGURE_PSD1_NAME = L"Microsoft.PowerToys.Configure.psd1"; -const wchar_t *DSC_CONFIGURE_PSM1_NAME = L"Microsoft.PowerToys.Configure.psm1"; - -UINT __stdcall InstallDSCModuleCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - std::wstring installationFolder; - - hr = WcaInitialize(hInstall, "InstallDSCModuleCA"); - ExitOnFailure(hr, "Failed to initialize"); - - hr = getInstallFolder(hInstall, installationFolder); - ExitOnFailure(hr, "Failed to get installFolder."); - - { - const auto baseModulesPath = GetUserPowerShellModulesPath(); - if (baseModulesPath.empty()) - { - hr = E_FAIL; - ExitOnFailure(hr, "Unable to determine Powershell modules path"); - } - - const auto modulesPath = baseModulesPath / L"Microsoft.PowerToys.Configure" / (get_product_version(false) + L".0"); - - std::error_code errorCode; - fs::create_directories(modulesPath, errorCode); - if (errorCode) - { - hr = E_FAIL; - ExitOnFailure(hr, "Unable to create Powershell modules folder"); - } - - for (const auto *filename : {DSC_CONFIGURE_PSD1_NAME, DSC_CONFIGURE_PSM1_NAME}) - { - fs::copy_file(fs::path(installationFolder) / "DSCModules" / filename, modulesPath / filename, fs::copy_options::overwrite_existing, errorCode); - - if (errorCode) - { - hr = E_FAIL; - ExitOnFailure(hr, "Unable to copy Powershell modules file"); - } - } - } - -LExit: - if (SUCCEEDED(hr)) - { - er = ERROR_SUCCESS; - Logger::info(L"DSC module was installed!"); - } - else - { - er = ERROR_INSTALL_FAILURE; - Logger::error(L"Couldn't install DSC module!"); - } - - return WcaFinalize(er); -} - -UINT __stdcall UninstallDSCModuleCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "UninstallDSCModuleCA"); - ExitOnFailure(hr, "Failed to initialize"); - - { - const auto baseModulesPath = GetUserPowerShellModulesPath(); - if (baseModulesPath.empty()) - { - hr = E_FAIL; - ExitOnFailure(hr, "Unable to determine Powershell modules path"); - } - - const auto powerToysModulePath = baseModulesPath / L"Microsoft.PowerToys.Configure"; - const auto versionedModulePath = powerToysModulePath / (get_product_version(false) + L".0"); - - std::error_code errorCode; - - for (const auto *filename : {DSC_CONFIGURE_PSD1_NAME, DSC_CONFIGURE_PSM1_NAME}) - { - fs::remove(versionedModulePath / filename, errorCode); - - if (errorCode) - { - hr = E_FAIL; - ExitOnFailure(hr, "Unable to delete DSC file"); - } - } - - for (const auto *modulePath : {&versionedModulePath, &powerToysModulePath}) - { - fs::remove(*modulePath, errorCode); - - if (errorCode) - { - hr = E_FAIL; - ExitOnFailure(hr, "Unable to delete DSC folder"); - } - } - } - -LExit: - if (SUCCEEDED(hr)) - { - er = ERROR_SUCCESS; - Logger::info(L"DSC module was uninstalled!"); - } - else - { - er = ERROR_INSTALL_FAILURE; - Logger::error(L"Couldn't uninstall DSC module!"); - } - - return WcaFinalize(er); -} - -UINT __stdcall InstallEmbeddedMSIXCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "InstallEmbeddedMSIXCA"); - ExitOnFailure(hr, "Failed to initialize"); - - if (auto msix = RcResource::create(IDR_BIN_MSIX_HELLO_PACKAGE, L"BIN", DLL_HANDLE)) - { - Logger::info(L"Extracted MSIX"); - // TODO: Use to activate embedded MSIX - const auto msix_path = std::filesystem::temp_directory_path() / "hello_package.msix"; - if (!msix->saveAsFile(msix_path)) - { - ExitOnFailure(hr, "Failed to save msix"); - } - Logger::info(L"Saved MSIX"); - using namespace winrt::Windows::Management::Deployment; - using namespace winrt::Windows::Foundation; - - Uri msix_uri{msix_path.wstring()}; - PackageManager pm; - auto result = pm.AddPackageAsync(msix_uri, nullptr, DeploymentOptions::None).get(); - if (!result) - { - ExitOnFailure(hr, "Failed to AddPackage"); - } - - Logger::info(L"MSIX[s] were installed!"); - } - else - { - ExitOnFailure(hr, "Failed to extract msix"); - } - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall UninstallEmbeddedMSIXCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - using namespace winrt::Windows::Management::Deployment; - using namespace winrt::Windows::Foundation; - // TODO: This must be replaced with the actual publisher and package name - const wchar_t package_name[] = L"46b35c25-b593-48d5-aeb1-d3e9c3b796e9"; - const wchar_t publisher[] = L"CN=yuyoyuppe"; - PackageManager pm; - - hr = WcaInitialize(hInstall, "UninstallEmbeddedMSIXCA"); - ExitOnFailure(hr, "Failed to initialize"); - - for (const auto &p : pm.FindPackagesForUser({}, package_name, publisher)) - { - auto result = pm.RemovePackageAsync(p.Id().FullName()).get(); - if (result) - { - Logger::info(L"MSIX was uninstalled!"); - } - else - { - Logger::error(L"Couldn't uninstall MSIX!"); - } - } - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall RemoveWindowsServiceByName(std::wstring serviceName) -{ - SC_HANDLE hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CONNECT); - - if (!hSCManager) - { - return ERROR_INSTALL_FAILURE; - } - - SC_HANDLE hService = OpenService(hSCManager, serviceName.c_str(), SERVICE_STOP | DELETE); - if (!hService) - { - CloseServiceHandle(hSCManager); - return ERROR_INSTALL_FAILURE; - } - - SERVICE_STATUS ss; - if (ControlService(hService, SERVICE_CONTROL_STOP, &ss)) - { - Sleep(1000); - while (QueryServiceStatus(hService, &ss)) - { - if (ss.dwCurrentState == SERVICE_STOP_PENDING) - { - Sleep(1000); - } - else - { - break; - } - } - } - - BOOL deleteResult = DeleteService(hService); - CloseServiceHandle(hService); - CloseServiceHandle(hSCManager); - - if (!deleteResult) - { - return ERROR_INSTALL_FAILURE; - } - - return ERROR_SUCCESS; -} - -UINT __stdcall UnsetAdvancedPasteAPIKeyCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - try - { - winrt::Windows::Security::Credentials::PasswordVault vault; - winrt::Windows::Security::Credentials::PasswordCredential cred; - - hr = WcaInitialize(hInstall, "UnsetAdvancedPasteAPIKey"); - ExitOnFailure(hr, "Failed to initialize"); - - cred = vault.Retrieve(L"https://platform.openai.com/api-keys", L"PowerToys_AdvancedPaste_OpenAIKey"); - vault.Remove(cred); - } - catch (...) - { - } - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall UninstallCommandNotFoundModuleCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - std::wstring installationFolder; - std::string command; - - hr = WcaInitialize(hInstall, "UninstallCommandNotFoundModule"); - ExitOnFailure(hr, "Failed to initialize"); - - hr = getInstallFolder(hInstall, installationFolder); - ExitOnFailure(hr, "Failed to get installFolder."); - -#ifdef _M_ARM64 - command = "powershell.exe"; - command += " "; - command += "-NoProfile -NonInteractive -NoLogo -WindowStyle Hidden -ExecutionPolicy Unrestricted"; - command += " -Command "; - command += "\"[Environment]::SetEnvironmentVariable('PATH', [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'Process');"; - command += "pwsh.exe -NoProfile -NonInteractive -NoLogo -WindowStyle Hidden -ExecutionPolicy Unrestricted -File '" + winrt::to_string(installationFolder) + "\\WinUI3Apps\\Assets\\Settings\\Scripts\\DisableModule.ps1" + "'\""; -#else - command = "pwsh.exe"; - command += " "; - command += "-NoProfile -NonInteractive -NoLogo -WindowStyle Hidden -ExecutionPolicy Unrestricted -File \"" + winrt::to_string(installationFolder) + "\\WinUI3Apps\\Assets\\Settings\\Scripts\\DisableModule.ps1" + "\""; -#endif - - system(command.c_str()); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall UpgradeCommandNotFoundModuleCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - std::wstring installationFolder; - std::string command; - - hr = WcaInitialize(hInstall, "UpgradeCommandNotFoundModule"); - ExitOnFailure(hr, "Failed to initialize"); - - hr = getInstallFolder(hInstall, installationFolder); - ExitOnFailure(hr, "Failed to get installFolder."); - - command = "pwsh.exe"; - command += " "; - command += "-NoProfile -NonInteractive -NoLogo -WindowStyle Hidden -ExecutionPolicy Unrestricted -File \"" + winrt::to_string(installationFolder) + "\\WinUI3Apps\\Assets\\Settings\\Scripts\\UpgradeModule.ps1" + "\""; - - system(command.c_str()); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall UninstallServicesCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "UninstallServicesCA"); - - ExitOnFailure(hr, "Failed to initialize"); - - hr = RemoveWindowsServiceByName(L"PowerToys.MWB.Service"); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -// Removes all Scheduled Tasks in the PowerToys folder and deletes the folder afterwards. -// Based on the Task Scheduler Displaying Task Names and State example: -// https://learn.microsoft.com/windows/desktop/TaskSchd/displaying-task-names-and-state--c---/ -UINT __stdcall RemoveScheduledTasksCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - ITaskService *pService = nullptr; - ITaskFolder *pTaskFolder = nullptr; - IRegisteredTaskCollection *pTaskCollection = nullptr; - ITaskFolder *pRootFolder = nullptr; - LONG numTasks = 0; - - hr = WcaInitialize(hInstall, "RemoveScheduledTasksCA"); - ExitOnFailure(hr, "Failed to initialize"); - - Logger::info(L"RemoveScheduledTasksCA Initialized."); - - // COM and Security Initialization is expected to have been done by the MSI. - // It couldn't be done in the DLL, anyway. - // ------------------------------------------------------ - // Create an instance of the Task Service. - hr = CoCreateInstance(CLSID_TaskScheduler, - nullptr, - CLSCTX_INPROC_SERVER, - IID_ITaskService, - reinterpret_cast(&pService)); - ExitOnFailure(hr, "Failed to create an instance of ITaskService: %x", hr); - - // Connect to the task service. - hr = pService->Connect(_variant_t(), _variant_t(), _variant_t(), _variant_t()); - ExitOnFailure(hr, "ITaskService::Connect failed: %x", hr); - - // ------------------------------------------------------ - // Get the PowerToys task folder. - hr = pService->GetFolder(_bstr_t(L"\\PowerToys"), &pTaskFolder); - if (FAILED(hr)) - { - // Folder doesn't exist. No need to delete anything. - Logger::info(L"The PowerToys scheduled task folder wasn't found. Nothing to delete."); - hr = S_OK; - ExitFunction(); - } - - // ------------------------------------------------------- - // Get the registered tasks in the folder. - hr = pTaskFolder->GetTasks(TASK_ENUM_HIDDEN, &pTaskCollection); - ExitOnFailure(hr, "Cannot get the registered tasks: %x", hr); - - hr = pTaskCollection->get_Count(&numTasks); - for (LONG i = 0; i < numTasks; i++) - { - // Delete all the tasks found. - // If some tasks can't be deleted, the folder won't be deleted later and the user will still be notified. - IRegisteredTask *pRegisteredTask = nullptr; - hr = pTaskCollection->get_Item(_variant_t(i + 1), &pRegisteredTask); - if (SUCCEEDED(hr)) - { - BSTR taskName = nullptr; - hr = pRegisteredTask->get_Name(&taskName); - if (SUCCEEDED(hr)) - { - hr = pTaskFolder->DeleteTask(taskName, 0); - if (FAILED(hr)) - { - Logger::error(L"Cannot delete the {} task: {}", taskName, hr); - } - SysFreeString(taskName); - } - else - { - Logger::error(L"Cannot get the registered task name: {}", hr); - } - pRegisteredTask->Release(); - } - else - { - Logger::error(L"Cannot get the registered task item at index={}: {}", i + 1, hr); - } - } - - // ------------------------------------------------------ - // Get the pointer to the root task folder and delete the PowerToys subfolder. - hr = pService->GetFolder(_bstr_t(L"\\"), &pRootFolder); - ExitOnFailure(hr, "Cannot get Root Folder pointer: %x", hr); - hr = pRootFolder->DeleteFolder(_bstr_t(L"PowerToys"), 0); - pRootFolder->Release(); - ExitOnFailure(hr, "Cannot delete the PowerToys folder: %x", hr); - - Logger::info(L"Deleted the PowerToys Task Scheduler folder."); - -LExit: - if (pService) - { - pService->Release(); - } - if (pTaskFolder) - { - pTaskFolder->Release(); - } - if (pTaskCollection) - { - pTaskCollection->Release(); - } - - if (!SUCCEEDED(hr)) - { - PMSIHANDLE hRecord = MsiCreateRecord(0); - MsiRecordSetString(hRecord, 0, TEXT("Failed to remove the PowerToys folder from the scheduled task. These can be removed manually later.")); - MsiProcessMessage(hInstall, static_cast(INSTALLMESSAGE_WARNING + MB_OK), hRecord); - } - - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall TelemetryLogInstallSuccessCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "TelemetryLogInstallSuccessCA"); - ExitOnFailure(hr, "Failed to initialize"); - - TraceLoggingWriteWrapper( - g_hProvider, - "Install_Success", - TraceLoggingWideString(get_product_version().c_str(), "Version"), - ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall TelemetryLogInstallCancelCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "TelemetryLogInstallCancelCA"); - ExitOnFailure(hr, "Failed to initialize"); - - TraceLoggingWriteWrapper( - g_hProvider, - "Install_Cancel", - TraceLoggingWideString(get_product_version().c_str(), "Version"), - ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall TelemetryLogInstallFailCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "TelemetryLogInstallFailCA"); - ExitOnFailure(hr, "Failed to initialize"); - - TraceLoggingWriteWrapper( - g_hProvider, - "Install_Fail", - TraceLoggingWideString(get_product_version().c_str(), "Version"), - ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall TelemetryLogUninstallSuccessCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "TelemetryLogUninstallSuccessCA"); - ExitOnFailure(hr, "Failed to initialize"); - - TraceLoggingWriteWrapper( - g_hProvider, - "UnInstall_Success", - TraceLoggingWideString(get_product_version().c_str(), "Version"), - ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall TelemetryLogUninstallCancelCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "TelemetryLogUninstallCancelCA"); - ExitOnFailure(hr, "Failed to initialize"); - - TraceLoggingWriteWrapper( - g_hProvider, - "UnInstall_Cancel", - TraceLoggingWideString(get_product_version().c_str(), "Version"), - ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall TelemetryLogUninstallFailCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "TelemetryLogUninstallFailCA"); - ExitOnFailure(hr, "Failed to initialize"); - - TraceLoggingWriteWrapper( - g_hProvider, - "UnInstall_Fail", - TraceLoggingWideString(get_product_version().c_str(), "Version"), - ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall TelemetryLogRepairCancelCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "TelemetryLogRepairCancelCA"); - ExitOnFailure(hr, "Failed to initialize"); - - TraceLoggingWriteWrapper( - g_hProvider, - "Repair_Cancel", - TraceLoggingWideString(get_product_version().c_str(), "Version"), - ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall TelemetryLogRepairFailCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "TelemetryLogRepairFailCA"); - ExitOnFailure(hr, "Failed to initialize"); - - TraceLoggingWriteWrapper( - g_hProvider, - "Repair_Fail", - TraceLoggingWideString(get_product_version().c_str(), "Version"), - ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); - -LExit: - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall DetectPrevInstallPathCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "DetectPrevInstallPathCA"); - MsiSetPropertyW(hInstall, L"PREVIOUSINSTALLFOLDER", L""); - - LPWSTR currentScope = nullptr; - hr = WcaGetProperty(L"InstallScope", ¤tScope); - - try - { - if (auto install_path = GetMsiPackageInstalledPath(std::wstring{currentScope} == L"perUser")) - { - MsiSetPropertyW(hInstall, L"PREVIOUSINSTALLFOLDER", install_path->data()); - } - } - catch (...) - { - } - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -UINT __stdcall InstallCmdPalPackageCA(MSIHANDLE hInstall) -{ - using namespace winrt::Windows::Foundation; - using namespace winrt::Windows::Management::Deployment; - - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - std::wstring installationFolder; - - hr = WcaInitialize(hInstall, "InstallCmdPalPackage"); - hr = getInstallFolder(hInstall, installationFolder); - - try - { - auto msix = package::FindMsixFile(installationFolder + L"\\WinUI3Apps\\CmdPal\\", false); - auto dependencies = package::FindMsixFile(installationFolder + L"\\WinUI3Apps\\CmdPal\\Dependencies\\", true); - - if (!msix.empty()) - { - auto msixPath = msix[0]; - - if (!package::RegisterPackage(msixPath, dependencies)) - { - Logger::error(L"Failed to install CmdPal package"); - er = ERROR_INSTALL_FAILURE; - } - } - } - catch (std::exception &e) - { - std::string errorMessage{"Exception thrown while trying to install CmdPal package: "}; - errorMessage += e.what(); - Logger::error(errorMessage); - - er = ERROR_INSTALL_FAILURE; - } - - er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; - return WcaFinalize(er); -} - -UINT __stdcall UnRegisterCmdPalPackageCA(MSIHANDLE hInstall) -{ - using namespace winrt::Windows::Foundation; - using namespace winrt::Windows::Management::Deployment; - - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "UnRegisterCmdPalPackageCA"); - - try - { - // Packages to unregister - std::wstring packageToRemoveDisplayName {L"Microsoft.CommandPalette"}; - - if (!package::UnRegisterPackage(packageToRemoveDisplayName)) - { - Logger::error(L"Failed to unregister package: " + packageToRemoveDisplayName); - er = ERROR_INSTALL_FAILURE; - } - } - catch (std::exception &e) - { - std::string errorMessage{"Exception thrown while trying to unregister the CmdPal package: "}; - errorMessage += e.what(); - Logger::error(errorMessage); - - er = ERROR_INSTALL_FAILURE; - } - - er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; - return WcaFinalize(er); -} - - -UINT __stdcall UnRegisterContextMenuPackagesCA(MSIHANDLE hInstall) -{ - using namespace winrt::Windows::Foundation; - using namespace winrt::Windows::Management::Deployment; - - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - - hr = WcaInitialize(hInstall, "UnRegisterContextMenuPackagesCA"); // original func name is too long - - try - { - // Packages to unregister - const std::vector packagesToRemoveDisplayName{{L"PowerRenameContextMenu"}, {L"ImageResizerContextMenu"}, {L"FileLocksmithContextMenu"}, {L"NewPlusContextMenu"}}; - - for (auto const &package : packagesToRemoveDisplayName) - { - if (!package::UnRegisterPackage(package)) - { - Logger::error(L"Failed to unregister package: " + package); - er = ERROR_INSTALL_FAILURE; - } - } - } - catch (std::exception &e) - { - std::string errorMessage{"Exception thrown while trying to unregister sparse packages: "}; - errorMessage += e.what(); - Logger::error(errorMessage); - - er = ERROR_INSTALL_FAILURE; - } - - er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; - return WcaFinalize(er); -} - -UINT __stdcall CleanImageResizerRuntimeRegistryCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "CleanImageResizerRuntimeRegistryCA"); - - try - { - const wchar_t* CLSID_STR = L"{51B4D7E5-7568-4234-B4BB-47FB3C016A69}"; - const wchar_t* exts[] = { L".bmp", L".dib", L".gif", L".jfif", L".jpe", L".jpeg", L".jpg", L".jxr", L".png", L".rle", L".tif", L".tiff", L".wdp" }; - - auto deleteKeyRecursive = [](HKEY root, const std::wstring &path) { - RegDeleteTreeW(root, path.c_str()); - }; - - // InprocServer32 chain root CLSID - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\CLSID\\" + std::wstring(CLSID_STR)); - // DragDrop handler - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\Directory\\ShellEx\\DragDropHandlers\\ImageResizer"); - // Extensions - for (auto ext : exts) - { - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\SystemFileAssociations\\" + std::wstring(ext) + L"\\ShellEx\\ContextMenuHandlers\\ImageResizer"); - } - // Sentinel - RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerToys\\ImageResizer"); - } - catch (...) - { - er = ERROR_INSTALL_FAILURE; - } - - er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; - return WcaFinalize(er); -} - -UINT __stdcall CleanFileLocksmithRuntimeRegistryCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "CleanFileLocksmithRuntimeRegistryCA"); - try - { - const wchar_t* CLSID_STR = L"{84D68575-E186-46AD-B0CB-BAEB45EE29C0}"; - auto deleteKeyRecursive = [](HKEY root, const std::wstring& path) { - RegDeleteTreeW(root, path.c_str()); - }; - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\CLSID\\" + std::wstring(CLSID_STR)); - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt"); - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\Drive\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt"); - RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerToys\\FileLocksmith"); - } - catch (...) - { - er = ERROR_INSTALL_FAILURE; - } - er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; - return WcaFinalize(er); -} - -UINT __stdcall CleanPowerRenameRuntimeRegistryCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "CleanPowerRenameRuntimeRegistryCA"); - try - { - const wchar_t* CLSID_STR = L"{0440049F-D1DC-4E46-B27B-98393D79486B}"; - auto deleteKeyRecursive = [](HKEY root, const std::wstring& path) { - RegDeleteTreeW(root, path.c_str()); - }; - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\CLSID\\" + std::wstring(CLSID_STR)); - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\PowerRenameExt"); - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\PowerRenameExt"); - RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerToys\\PowerRename"); - } - catch (...) - { - er = ERROR_INSTALL_FAILURE; - } - er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; - return WcaFinalize(er); -} - -UINT __stdcall CleanNewPlusRuntimeRegistryCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "CleanNewPlusRuntimeRegistryCA"); - try - { - const wchar_t* CLSID_STR = L"{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"; - auto deleteKeyRecursive = [](HKEY root, const std::wstring& path) { - RegDeleteTreeW(root, path.c_str()); - }; - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\CLSID\\" + std::wstring(CLSID_STR)); - deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\NewPlusShellExtensionWin10"); - RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerToys\\NewPlus"); - } - catch (...) - { - er = ERROR_INSTALL_FAILURE; - } - er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; - return WcaFinalize(er); -} - -UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) -{ - HRESULT hr = S_OK; - UINT er = ERROR_SUCCESS; - hr = WcaInitialize(hInstall, "TerminateProcessesCA"); - - std::vector processes; - const size_t maxProcesses = 4096; - DWORD bytes = maxProcesses * sizeof(processes[0]); - processes.resize(maxProcesses); - - if (!EnumProcesses(processes.data(), bytes, &bytes)) - { - return 1; - } - processes.resize(bytes / sizeof(processes[0])); - - std::array processesToTerminate = { - L"PowerToys.PowerLauncher.exe", - L"PowerToys.Settings.exe", - L"PowerToys.AdvancedPaste.exe", - L"PowerToys.Awake.exe", - L"PowerToys.FancyZones.exe", - L"PowerToys.FancyZonesEditor.exe", - L"PowerToys.FileLocksmithUI.exe", - L"PowerToys.MouseJumpUI.exe", - L"PowerToys.ColorPickerUI.exe", - L"PowerToys.AlwaysOnTop.exe", - L"PowerToys.RegistryPreview.exe", - L"PowerToys.Hosts.exe", - L"PowerToys.PowerRename.exe", - L"PowerToys.ImageResizer.exe", - L"PowerToys.GcodeThumbnailProvider.exe", - L"PowerToys.BgcodeThumbnailProvider.exe", - L"PowerToys.PdfThumbnailProvider.exe", - L"PowerToys.MonacoPreviewHandler.exe", - L"PowerToys.MarkdownPreviewHandler.exe", - L"PowerToys.StlThumbnailProvider.exe", - L"PowerToys.SvgThumbnailProvider.exe", - L"PowerToys.GcodePreviewHandler.exe", - L"PowerToys.BgcodePreviewHandler.exe", - L"PowerToys.QoiPreviewHandler.exe", - L"PowerToys.PdfPreviewHandler.exe", - L"PowerToys.QoiThumbnailProvider.exe", - L"PowerToys.SvgPreviewHandler.exe", - L"PowerToys.Peek.UI.exe", - L"PowerToys.MouseWithoutBorders.exe", - L"PowerToys.MouseWithoutBordersHelper.exe", - L"PowerToys.MouseWithoutBordersService.exe", - L"PowerToys.CropAndLock.exe", - L"PowerToys.EnvironmentVariables.exe", - L"PowerToys.WorkspacesSnapshotTool.exe", - L"PowerToys.WorkspacesLauncher.exe", - L"PowerToys.WorkspacesLauncherUI.exe", - L"PowerToys.WorkspacesEditor.exe", - L"PowerToys.WorkspacesWindowArranger.exe", - L"Microsoft.CmdPal.UI.exe", - L"PowerToys.ZoomIt.exe", - L"PowerToys.exe", - }; - - for (const auto procID : processes) - { - if (!procID) - { - continue; - } - wchar_t processName[MAX_PATH] = L""; - - HANDLE hProcess{OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | PROCESS_TERMINATE, FALSE, procID)}; - if (!hProcess) - { - continue; - } - HMODULE hMod; - DWORD cbNeeded; - - if (!EnumProcessModules(hProcess, &hMod, sizeof(hMod), &cbNeeded)) - { - CloseHandle(hProcess); - continue; - } - GetModuleBaseNameW(hProcess, hMod, processName, sizeof(processName) / sizeof(wchar_t)); - - for (const auto processToTerminate : processesToTerminate) - { - if (processName == processToTerminate) - { - const DWORD timeout = 500; - auto windowEnumerator = [](HWND hwnd, LPARAM procIDPtr) -> BOOL - { - auto targetProcID = *reinterpret_cast(procIDPtr); - DWORD windowProcID = 0; - GetWindowThreadProcessId(hwnd, &windowProcID); - if (windowProcID == targetProcID) - { - DWORD_PTR _{}; - SendMessageTimeoutA(hwnd, WM_CLOSE, 0, 0, SMTO_BLOCK, timeout, &_); - } - return TRUE; - }; - EnumWindows(windowEnumerator, reinterpret_cast(&procID)); - Sleep(timeout); - TerminateProcess(hProcess, 0); - break; - } - } - CloseHandle(hProcess); - } - - er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; - return WcaFinalize(er); -} - -void initSystemLogger() -{ - static std::once_flag initLoggerFlag; - std::call_once(initLoggerFlag, []() - { - WCHAR temp_path[MAX_PATH]; - auto ret = GetTempPath(MAX_PATH, temp_path); - - if (ret) - { - Logger::init("PowerToysMSI", std::wstring{ temp_path } + L"\\PowerToysMSIInstaller", L""); - } }); -} - -// DllMain - Initialize and cleanup WiX custom action utils. -extern "C" BOOL WINAPI DllMain(__in HINSTANCE hInst, __in ULONG ulReason, __in LPVOID) -{ - switch (ulReason) - { - case DLL_PROCESS_ATTACH: - WcaGlobalInitialize(hInst); - initSystemLogger(); - TraceLoggingRegister(g_hProvider); - DLL_HANDLE = hInst; - break; - - case DLL_PROCESS_DETACH: - TraceLoggingUnregister(g_hProvider); - WcaGlobalFinalize(); - break; - } - - return TRUE; -} diff --git a/installer/PowerToysSetupCustomActions/CustomAction.def b/installer/PowerToysSetupCustomActions/CustomAction.def deleted file mode 100644 index 9467ca2204..0000000000 --- a/installer/PowerToysSetupCustomActions/CustomAction.def +++ /dev/null @@ -1,34 +0,0 @@ -LIBRARY "PowerToysSetupCustomActions" - -EXPORTS - LaunchPowerToysCA - CheckGPOCA - CleanVideoConferenceRegistryCA - ApplyModulesRegistryChangeSetsCA - DetectPrevInstallPathCA - RemoveScheduledTasksCA - TelemetryLogInstallSuccessCA - TelemetryLogInstallCancelCA - TelemetryLogInstallFailCA - TelemetryLogUninstallSuccessCA - TelemetryLogUninstallCancelCA - TelemetryLogUninstallFailCA - TelemetryLogRepairCancelCA - TelemetryLogRepairFailCA - TerminateProcessesCA - InstallEmbeddedMSIXCA - InstallDSCModuleCA - InstallCmdPalPackageCA - UnApplyModulesRegistryChangeSetsCA - UnRegisterCmdPalPackageCA - UnRegisterContextMenuPackagesCA - UninstallEmbeddedMSIXCA - UninstallDSCModuleCA - UninstallServicesCA - UninstallCommandNotFoundModuleCA - UpgradeCommandNotFoundModuleCA - UnsetAdvancedPasteAPIKeyCA - CleanImageResizerRuntimeRegistryCA - CleanFileLocksmithRuntimeRegistryCA - CleanPowerRenameRuntimeRegistryCA - CleanNewPlusRuntimeRegistryCA diff --git a/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj b/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj deleted file mode 100644 index 0974bddbf9..0000000000 --- a/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj +++ /dev/null @@ -1,180 +0,0 @@ - - - - - - {32f3882b-f2d6-4586-b5ed-11e39e522bd3} - Win32Proj - PowerToysSetupCustomActions - PowerToysSetupCustomActions - - - - DynamicLibrary - Unicode - v143 - - - DynamicLibrary - Unicode - true - v143 - - - - - - - - - - - - - - $(Platform)\$(Configuration)\MachineSetup\ - $(Platform)\$(Configuration)\UserSetup\ - $(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\MachineSetup\obj\ - $(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\UserSetup\obj\ - - false - true - - - true - - - false - ..\..\src\common\Telemetry;$(IncludePath) - - - - - call cmd /C "copy ""$(ProjectDir)DepsFilesLists.h"" ""$(ProjectDir)DepsFilesLists.h.bk""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\AdvancedPaste.wxs"" ""$(ProjectDir)..\PowerToysSetup\AdvancedPaste.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Awake.wxs"" ""$(ProjectDir)..\PowerToysSetup\Awake.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\BaseApplications.wxs"" ""$(ProjectDir)..\PowerToysSetup\BaseApplications.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\CmdPal.wxs"" ""$(ProjectDir)..\PowerToysSetup\CmdPal.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\ColorPicker.wxs"" ""$(ProjectDir)..\PowerToysSetup\ColorPicker.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Core.wxs"" ""$(ProjectDir)..\PowerToysSetup\Core.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\EnvironmentVariables.wxs"" ""$(ProjectDir)..\PowerToysSetup\EnvironmentVariables.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\FileExplorerPreview.wxs"" ""$(ProjectDir)..\PowerToysSetup\FileExplorerPreview.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\FileLocksmith.wxs"" ""$(ProjectDir)..\PowerToysSetup\FileLocksmith.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Hosts.wxs"" ""$(ProjectDir)..\PowerToysSetup\Hosts.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\ImageResizer.wxs"" ""$(ProjectDir)..\PowerToysSetup\ImageResizer.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\KeyboardManager.wxs"" ""$(ProjectDir)..\PowerToysSetup\KeyboardManager.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\MouseWithoutBorders.wxs"" ""$(ProjectDir)..\PowerToysSetup\MouseWithoutBorders.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\NewPlus.wxs"" ""$(ProjectDir)..\PowerToysSetup\NewPlus.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Peek.wxs"" ""$(ProjectDir)..\PowerToysSetup\Peek.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\PowerRename.wxs"" ""$(ProjectDir)..\PowerToysSetup\PowerRename.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Product.wxs"" ""$(ProjectDir)..\PowerToysSetup\Product.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\RegistryPreview.wxs"" ""$(ProjectDir)..\PowerToysSetup\RegistryPreview.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Resources.wxs"" ""$(ProjectDir)..\PowerToysSetup\Resources.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Run.wxs"" ""$(ProjectDir)..\PowerToysSetup\Run.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Settings.wxs"" ""$(ProjectDir)..\PowerToysSetup\Settings.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\ShortcutGuide.wxs"" ""$(ProjectDir)..\PowerToysSetup\ShortcutGuide.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Tools.wxs"" ""$(ProjectDir)..\PowerToysSetup\Tools.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\WinAppSDK.wxs"" ""$(ProjectDir)..\PowerToysSetup\WinAppSDK.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\WinUI3Applications.wxs"" ""$(ProjectDir)..\PowerToysSetup\WinUI3Applications.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Workspaces.wxs"" ""$(ProjectDir)..\PowerToysSetup\Workspaces.wxs.bk"""" - if not "$(NormalizedPerUserValue)" == "true" call powershell.exe -NonInteractive -executionpolicy Unrestricted -File ..\PowerToysSetup\generateAllFileComponents.ps1 -platform $(Platform) - if "$(NormalizedPerUserValue)" == "true" call powershell.exe -NonInteractive -executionpolicy Unrestricted -File ..\PowerToysSetup\generateAllFileComponents.ps1 -platform $(Platform) -installscopeperuser $(NormalizedPerUserValue) - - Backing up original files and populating .NET and WPF Runtime dependencies for WiX3 based installer - - - - false - false - - - - inc;..\..\src\;..\..\src\common\Telemetry;telemetry;$(WixSdkPath)VS2017\inc;%(AdditionalIncludeDirectories) - /await /Zc:twoPhase- /Wv:18 %(AdditionalOptions) - Level4 - ProgramDatabase - - - Userenv.lib;Wtsapi32.lib;WindowsApp.lib;Newdev.lib;Crypt32.lib;msi.lib;wcautil.lib;Psapi.lib;Pathcch.lib;comsupp.lib;taskschd.lib;Secur32.lib;msi.lib;dutil.lib;wcautil.lib;Version.lib;Shlwapi.lib;%(AdditionalDependencies) - CustomAction.def - - - - - WIN64;%(PreprocessorDefinitions) - - - $(WixSdkPath)VS2017\lib\$(Platform);%(AdditionalLibraryDirectories) - - - - - Disabled - _DEBUG;_WINDOWS;_USRDLL;CUSTOMACTIONTEST_EXPORTS;%(PreprocessorDefinitions) - EnableFastChecks - MultiThreadedDebug - - - true - Windows - HighestAvailable - - - - - MaxSpeed - true - NDEBUG;_WINDOWS;_USRDLL;CUSTOMACTIONTEST_EXPORTS;%(PreprocessorDefinitions) - MultiThreaded - true - - - true - Windows - true - true - HighestAvailable - - - - - - Create - - - - - - - - - - - - - - - - - - - - {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} - - - {8f021b46-362b-485c-bfba-ccf83e820cbd} - - - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - \ No newline at end of file diff --git a/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj.filters b/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj.filters deleted file mode 100644 index f4cf974dc0..0000000000 --- a/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj.filters +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - Telemetry - - - Telemetry - - - - - - - - - - - - {6e73ce5d-e715-4e7e-b796-c5d180b07ff2} - - - - - - \ No newline at end of file diff --git a/installer/PowerToysSetupCustomActions/RcResource.h b/installer/PowerToysSetupCustomActions/RcResource.h deleted file mode 100644 index aabbb532bc..0000000000 --- a/installer/PowerToysSetupCustomActions/RcResource.h +++ /dev/null @@ -1,63 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -class RcResource -{ -public: - const std::byte* _memory = nullptr; - size_t _size = 0; - - static inline std::optional create(int resource_id, const std::wstring_view resource_class, const HINSTANCE handle = nullptr) - { - const HRSRC resHandle = FindResourceW(handle, MAKEINTRESOURCEW(resource_id), resource_class.data()); - if (!resHandle) - { - return std::nullopt; - } - - const HGLOBAL memHandle = LoadResource(handle, resHandle); - if (!memHandle) - { - return std::nullopt; - } - - const size_t resSize = SizeofResource(handle, resHandle); - if (!resSize) - { - return std::nullopt; - } - - auto res = static_cast(LockResource(memHandle)); - if (!res) - { - return std::nullopt; - } - - return RcResource{ res, resSize }; - } - - inline bool saveAsFile(const std::filesystem::path destination) - { - std::fstream installerFile{ destination, std::ios_base::binary | std::ios_base::out | std::ios_base::trunc }; - if (!installerFile.is_open()) - { - return false; - } - - installerFile.write(reinterpret_cast(_memory), _size); - return true; - } - -private: - RcResource() = delete; - RcResource(const std::byte* memory, size_t size) : - _memory{ memory }, _size{ size } - { - } -}; diff --git a/installer/PowerToysSetupCustomActions/Resource.rc b/installer/PowerToysSetupCustomActions/Resource.rc deleted file mode 100644 index c5f90c330d..0000000000 --- a/installer/PowerToysSetupCustomActions/Resource.rc +++ /dev/null @@ -1,96 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#include -#include "resource.h" -#include "../../src/common/version/version.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -1 VERSIONINFO -FILEVERSION FILE_VERSION -PRODUCTVERSION PRODUCT_VERSION -FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG -FILEFLAGS VS_FF_DEBUG -#else -FILEFLAGS 0x0L -#endif -FILEOS VOS_NT_WINDOWS32 -FILETYPE VFT_DLL -FILESUBTYPE VFT2_UNKNOWN -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset - BEGIN - VALUE "CompanyName", COMPANY_NAME - VALUE "FileDescription", FILE_DESCRIPTION - VALUE "FileVersion", FILE_VERSION_STRING - VALUE "InternalName", INTERNAL_NAME - VALUE "LegalCopyright", COPYRIGHT_NOTE - VALUE "OriginalFilename", ORIGINAL_FILENAME - VALUE "ProductName", PRODUCT_NAME - VALUE "ProductVersion", PRODUCT_VERSION_STRING - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset - END -END - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_RUS) -LANGUAGE 25, 1 - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - -// TODO: Use to activate embedded MSIX -//IDR_BIN_MSIX_HELLO_PACKAGE BIN "..\\..\..\\src\\modules\\HelloModule\\AppPackages\\HelloModule_1.0.0.0_x64_Test\\HelloModule_1.0.0.0_x64.msix" - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/installer/PowerToysSetupCustomActions/pch.cpp b/installer/PowerToysSetupCustomActions/pch.cpp deleted file mode 100644 index 64b7eef6d6..0000000000 --- a/installer/PowerToysSetupCustomActions/pch.cpp +++ /dev/null @@ -1,5 +0,0 @@ -// pch.cpp: source file corresponding to the pre-compiled header - -#include "pch.h" - -// When you are using pre-compiled headers, this source file is necessary for compilation to succeed. diff --git a/installer/PowerToysSetupCustomActions/pch.h b/installer/PowerToysSetupCustomActions/pch.h deleted file mode 100644 index ebfdd0c258..0000000000 --- a/installer/PowerToysSetupCustomActions/pch.h +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers -#define DPSAPI_VERSION 1 -// Windows Header Files: -#include -#include -#include -#include -#include -#include - -// WiX Header Files: -#include - -#define SECURITY_WIN32 -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -#include diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp index 8c3ad76448..968fcc2530 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp @@ -3,6 +3,8 @@ #include "RcResource.h" #include #include +#include +#include #include "../../src/common/logger/logger.h" #include "../../src/common/utils/gpo.h" @@ -232,7 +234,9 @@ UINT __stdcall LaunchPowerToysCA(MSIHANDLE hInstall) auto action = [&commandLine](HANDLE userToken) { - STARTUPINFO startupInfo{.cb = sizeof(STARTUPINFO), .wShowWindow = SW_SHOWNORMAL}; + STARTUPINFO startupInfo = { 0 }; + startupInfo.cb = sizeof(STARTUPINFO); + startupInfo.wShowWindow = SW_SHOWNORMAL; PROCESS_INFORMATION processInformation; PVOID lpEnvironment = NULL; @@ -271,7 +275,9 @@ UINT __stdcall LaunchPowerToysCA(MSIHANDLE hInstall) } else { - STARTUPINFO startupInfo{.cb = sizeof(STARTUPINFO), .wShowWindow = SW_SHOWNORMAL}; + STARTUPINFO startupInfo = { 0 }; + startupInfo.cb = sizeof(STARTUPINFO); + startupInfo.wShowWindow = SW_SHOWNORMAL; PROCESS_INFORMATION processInformation; @@ -424,7 +430,7 @@ UINT __stdcall InstallDSCModuleCA(MSIHANDLE hInstall) const auto modulesPath = baseModulesPath / L"Microsoft.PowerToys.Configure" / (get_product_version(false) + L".0"); std::error_code errorCode; - fs::create_directories(modulesPath, errorCode); + std::filesystem::create_directories(modulesPath, errorCode); if (errorCode) { hr = E_FAIL; @@ -433,7 +439,7 @@ UINT __stdcall InstallDSCModuleCA(MSIHANDLE hInstall) for (const auto *filename : {DSC_CONFIGURE_PSD1_NAME, DSC_CONFIGURE_PSM1_NAME}) { - fs::copy_file(fs::path(installationFolder) / "DSCModules" / filename, modulesPath / filename, fs::copy_options::overwrite_existing, errorCode); + std::filesystem::copy_file(std::filesystem::path(installationFolder) / "DSCModules" / filename, modulesPath / filename, std::filesystem::copy_options::overwrite_existing, errorCode); if (errorCode) { @@ -481,7 +487,7 @@ UINT __stdcall UninstallDSCModuleCA(MSIHANDLE hInstall) for (const auto *filename : {DSC_CONFIGURE_PSD1_NAME, DSC_CONFIGURE_PSM1_NAME}) { - fs::remove(versionedModulePath / filename, errorCode); + std::filesystem::remove(versionedModulePath / filename, errorCode); if (errorCode) { @@ -492,7 +498,7 @@ UINT __stdcall UninstallDSCModuleCA(MSIHANDLE hInstall) for (const auto *modulePath : {&versionedModulePath, &powerToysModulePath}) { - fs::remove(*modulePath, errorCode); + std::filesystem::remove(*modulePath, errorCode); if (errorCode) { @@ -589,6 +595,216 @@ LExit: return WcaFinalize(er); } +UINT __stdcall InstallPackageIdentityMSIXCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + LPWSTR customActionData = nullptr; + std::wstring installFolderPath; + std::wstring installScope; + std::wstring msixPath; + std::wstring data; + size_t delimiterPos; + bool isMachineLevel = false; + + hr = WcaInitialize(hInstall, "InstallPackageIdentityMSIXCA"); + ExitOnFailure(hr, "Failed to initialize"); + + hr = WcaGetProperty(L"CustomActionData", &customActionData); + ExitOnFailure(hr, "Failed to get CustomActionData property"); + + // Parse CustomActionData: "[INSTALLFOLDER];[InstallScope]" + data = customActionData; + delimiterPos = data.find(L';'); + installFolderPath = data.substr(0, delimiterPos); + installScope = data.substr(delimiterPos + 1); + + // Check if this is a machine-level installation + if (installScope == L"perMachine") + { + isMachineLevel = true; + } + + Logger::info(L"Installing PackageIdentity MSIX - perUser: {}", !isMachineLevel); + + // Construct path to PackageIdentity MSIX + msixPath = installFolderPath; + msixPath += L"PowerToysSparse.msix"; + + if (std::filesystem::exists(msixPath)) + { + using namespace winrt::Windows::Management::Deployment; + using namespace winrt::Windows::Foundation; + + try + { + + std::wstring externalLocation = installFolderPath; // External content location (PowerToys install folder) + Uri externalUri{ externalLocation }; // External location URI for sparse package content + Uri packageUri{ msixPath }; // The MSIX file URI + + PackageManager packageManager; + + if (isMachineLevel) + { + // Machine-level installation + + StagePackageOptions stageOptions; + stageOptions.ExternalLocationUri(externalUri); + + auto stageResult = packageManager.StagePackageByUriAsync(packageUri, stageOptions).get(); + + uint32_t stageErrorCode = static_cast(stageResult.ExtendedErrorCode()); + if (stageErrorCode == 0) + { + std::wstring packageFamilyName = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe"; + + try + { + auto provisionResult = packageManager.ProvisionPackageForAllUsersAsync(packageFamilyName).get(); + uint32_t provisionErrorCode = static_cast(provisionResult.ExtendedErrorCode()); + + if (provisionErrorCode != 0) + { + Logger::error(L"Machine-level provisioning failed: 0x{:08X}", provisionErrorCode); + } + } + catch (const winrt::hresult_error& ex) + { + Logger::error(L"Provisioning exception: HRESULT 0x{:08X}", static_cast(ex.code())); + } + } + else + { + Logger::error(L"Package staging failed: 0x{:08X}", stageErrorCode); + } + } + else + { + AddPackageOptions addOptions; + addOptions.ExternalLocationUri(externalUri); + + auto addResult = packageManager.AddPackageByUriAsync(packageUri, addOptions).get(); + + if (!addResult.IsRegistered()) + { + uint32_t errorCode = static_cast(addResult.ExtendedErrorCode()); + Logger::error(L"Per-user installation failed: 0x{:08X}", errorCode); + } + } + } + catch (const std::exception& ex) + { + Logger::error(L"PackageIdentity MSIX installation failed - Exception: {}", + winrt::to_hstring(ex.what()).c_str()); + } + } + else + { + Logger::error(L"PackageIdentity MSIX not found: " + msixPath); + } + +LExit: + ReleaseStr(customActionData); + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; + return WcaFinalize(er); +} + +UINT __stdcall UninstallPackageIdentityMSIXCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + using namespace winrt::Windows::Management::Deployment; + using namespace winrt::Windows::Foundation; + + LPWSTR installScope = nullptr; + bool isMachineLevel = false; + + PackageManager pm; + + hr = WcaInitialize(hInstall, "UninstallPackageIdentityMSIXCA"); + ExitOnFailure(hr, "Failed to initialize"); + + // Check if this was a machine-level installation + hr = WcaGetProperty(L"InstallScope", &installScope); + if (SUCCEEDED(hr) && installScope && wcscmp(installScope, L"perMachine") == 0) + { + isMachineLevel = true; + } + + Logger::info(L"Uninstalling PackageIdentity MSIX - perUser: {}", !isMachineLevel); + + try + { + std::wstring packageFamilyName = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe"; + + if (isMachineLevel) + { + // Machine-level uninstallation: deprovision + remove for all users + + // First deprovision the package + try + { + auto deprovisionResult = pm.DeprovisionPackageForAllUsersAsync(packageFamilyName).get(); + if (deprovisionResult.IsRegistered()) + { + Logger::warn(L"Machine-level deprovisioning completed with warnings"); + } + } + catch (const winrt::hresult_error& ex) + { + Logger::warn(L"Machine-level deprovisioning failed: HRESULT 0x{:08X}", static_cast(ex.code())); + } + + // Then remove packages for all users + auto packages = pm.FindPackagesForUserWithPackageTypes({}, packageFamilyName, PackageTypes::Main); + for (const auto& package : packages) + { + try + { + auto machineResult = pm.RemovePackageAsync(package.Id().FullName(), RemovalOptions::RemoveForAllUsers).get(); + if (machineResult.IsRegistered()) + { + uint32_t errorCode = static_cast(machineResult.ExtendedErrorCode()); + Logger::error(L"Machine-level removal failed: 0x{:08X} - {}", errorCode, machineResult.ErrorText()); + } + } + catch (const winrt::hresult_error& ex) + { + Logger::error(L"Machine-level removal exception: HRESULT 0x{:08X}", static_cast(ex.code())); + } + } + } + else + { + // Per-user uninstallation: standard removal + + auto packages = pm.FindPackagesForUserWithPackageTypes({}, packageFamilyName, PackageTypes::Main); + for (const auto& package : packages) + { + auto userResult = pm.RemovePackageAsync(package.Id().FullName()).get(); + if (userResult.IsRegistered()) + { + uint32_t errorCode = static_cast(userResult.ExtendedErrorCode()); + Logger::error(L"Per-user removal failed: 0x{:08X} - {}", errorCode, userResult.ErrorText()); + } + } + } + } + catch (const std::exception& ex) + { + std::string errorMsg = "Failed to uninstall PackageIdentity MSIX: " + std::string(ex.what()); + Logger::error(errorMsg); + // Don't fail the entire uninstallation if PackageIdentity fails + Logger::warn(L"Continuing uninstallation despite PackageIdentity MSIX error"); + } + +LExit: + ReleaseStr(installScope); + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; + return WcaFinalize(er); +} + UINT __stdcall RemoveWindowsServiceByName(std::wstring serviceName) { SC_HANDLE hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CONNECT); @@ -641,14 +857,69 @@ UINT __stdcall UnsetAdvancedPasteAPIKeyCA(MSIHANDLE hInstall) try { - winrt::Windows::Security::Credentials::PasswordVault vault; - winrt::Windows::Security::Credentials::PasswordCredential cred; - hr = WcaInitialize(hInstall, "UnsetAdvancedPasteAPIKey"); ExitOnFailure(hr, "Failed to initialize"); - cred = vault.Retrieve(L"https://platform.openai.com/api-keys", L"PowerToys_AdvancedPaste_OpenAIKey"); - vault.Remove(cred); + winrt::Windows::Security::Credentials::PasswordVault vault; + + auto hasPrefix = [](std::wstring_view value, wchar_t const* prefix) { + std::wstring_view prefixView{ prefix }; + return value.compare(0, prefixView.size(), prefixView) == 0; + }; + + const wchar_t* resourcePrefixes[] = { + L"https://platform.openai.com/api-keys", + L"https://azure.microsoft.com/products/ai-services/openai-service", + L"https://azure.microsoft.com/products/ai-services/ai-inference", + L"https://console.mistral.ai/account/api-keys", + L"https://ai.google.dev/", + }; + + const wchar_t* usernamePrefixes[] = { + L"PowerToys_AdvancedPaste_", + }; + + auto credentials = vault.RetrieveAll(); + for (auto const& credential : credentials) + { + bool shouldRemove = false; + + std::wstring resource{ credential.Resource() }; + for (auto const prefix : resourcePrefixes) + { + if (hasPrefix(resource, prefix)) + { + shouldRemove = true; + break; + } + } + + if (!shouldRemove) + { + std::wstring username{ credential.UserName() }; + for (auto const prefix : usernamePrefixes) + { + if (hasPrefix(username, prefix)) + { + shouldRemove = true; + break; + } + } + } + + if (!shouldRemove) + { + continue; + } + + try + { + vault.Remove(credential); + } + catch (...) + { + } + } } catch (...) { @@ -1278,7 +1549,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) } processes.resize(bytes / sizeof(processes[0])); - std::array processesToTerminate = { + std::array processesToTerminate = { L"PowerToys.PowerLauncher.exe", L"PowerToys.Settings.exe", L"PowerToys.AdvancedPaste.exe", @@ -1293,6 +1564,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) L"PowerToys.Hosts.exe", L"PowerToys.PowerRename.exe", L"PowerToys.ImageResizer.exe", + L"PowerToys.LightSwitchService.exe", L"PowerToys.GcodeThumbnailProvider.exe", L"PowerToys.BgcodeThumbnailProvider.exe", L"PowerToys.PdfThumbnailProvider.exe", @@ -1375,6 +1647,120 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) return WcaFinalize(er); } +UINT __stdcall SetBundleInstallLocationCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + + // Declare all variables at the beginning to avoid goto issues + std::wstring customActionData; + std::wstring installationFolder; + std::wstring bundleUpgradeCode; + std::wstring installScope; + bool isPerUser = false; + size_t pos1 = std::wstring::npos; + size_t pos2 = std::wstring::npos; + std::vector keysToTry; + + hr = WcaInitialize(hInstall, "SetBundleInstallLocationCA"); + ExitOnFailure(hr, "Failed to initialize"); + + // Parse CustomActionData: "installFolder;upgradeCode;installScope" + hr = getInstallFolder(hInstall, customActionData); + ExitOnFailure(hr, "Failed to get CustomActionData."); + + pos1 = customActionData.find(L';'); + if (pos1 == std::wstring::npos) + { + hr = E_INVALIDARG; + ExitOnFailure(hr, "Invalid CustomActionData format - missing first semicolon"); + } + + pos2 = customActionData.find(L';', pos1 + 1); + if (pos2 == std::wstring::npos) + { + hr = E_INVALIDARG; + ExitOnFailure(hr, "Invalid CustomActionData format - missing second semicolon"); + } + + installationFolder = customActionData.substr(0, pos1); + bundleUpgradeCode = customActionData.substr(pos1 + 1, pos2 - pos1 - 1); + installScope = customActionData.substr(pos2 + 1); + + isPerUser = (installScope == L"perUser"); + + // Use the appropriate registry based on install scope + HKEY targetKey = isPerUser ? HKEY_CURRENT_USER : HKEY_LOCAL_MACHINE; + const wchar_t* keyName = isPerUser ? L"HKCU" : L"HKLM"; + + WcaLog(LOGMSG_STANDARD, "SetBundleInstallLocationCA: Searching for Bundle in %ls registry", keyName); + + HKEY uninstallKey; + LONG openResult = RegOpenKeyExW(targetKey, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", 0, KEY_READ | KEY_ENUMERATE_SUB_KEYS, &uninstallKey); + if (openResult != ERROR_SUCCESS) + { + WcaLog(LOGMSG_STANDARD, "SetBundleInstallLocationCA: Failed to open uninstall key, error: %ld", openResult); + goto LExit; + } + + DWORD index = 0; + wchar_t subKeyName[256]; + DWORD subKeyNameSize = sizeof(subKeyName) / sizeof(wchar_t); + + while (RegEnumKeyExW(uninstallKey, index, subKeyName, &subKeyNameSize, nullptr, nullptr, nullptr, nullptr) == ERROR_SUCCESS) + { + HKEY productKey; + if (RegOpenKeyExW(uninstallKey, subKeyName, 0, KEY_READ | KEY_WRITE, &productKey) == ERROR_SUCCESS) + { + wchar_t upgradeCode[256]; + DWORD upgradeCodeSize = sizeof(upgradeCode); + DWORD valueType; + + if (RegQueryValueExW(productKey, L"BundleUpgradeCode", nullptr, &valueType, + reinterpret_cast(upgradeCode), &upgradeCodeSize) == ERROR_SUCCESS) + { + // Remove brackets from registry upgradeCode for comparison (bundleUpgradeCode doesn't have brackets) + std::wstring regUpgradeCode = upgradeCode; + if (!regUpgradeCode.empty() && regUpgradeCode.front() == L'{' && regUpgradeCode.back() == L'}') + { + regUpgradeCode = regUpgradeCode.substr(1, regUpgradeCode.length() - 2); + } + + if (_wcsicmp(regUpgradeCode.c_str(), bundleUpgradeCode.c_str()) == 0) + { + // Found matching Bundle, set InstallLocation + LONG setResult = RegSetValueExW(productKey, L"InstallLocation", 0, REG_SZ, + reinterpret_cast(installationFolder.c_str()), + static_cast((installationFolder.length() + 1) * sizeof(wchar_t))); + + if (setResult == ERROR_SUCCESS) + { + WcaLog(LOGMSG_STANDARD, "SetBundleInstallLocationCA: InstallLocation set successfully"); + } + else + { + WcaLog(LOGMSG_STANDARD, "SetBundleInstallLocationCA: Failed to set InstallLocation, error: %ld", setResult); + } + + RegCloseKey(productKey); + RegCloseKey(uninstallKey); + goto LExit; + } + } + RegCloseKey(productKey); + } + + index++; + subKeyNameSize = sizeof(subKeyName) / sizeof(wchar_t); + } + + RegCloseKey(uninstallKey); + +LExit: + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; + return WcaFinalize(er); +} + void initSystemLogger() { static std::once_flag initLoggerFlag; diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.def b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def index 39efc9ff70..4bad107f16 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.def +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def @@ -32,4 +32,6 @@ EXPORTS CleanFileLocksmithRuntimeRegistryCA CleanPowerRenameRuntimeRegistryCA CleanNewPlusRuntimeRegistryCA - \ No newline at end of file + SetBundleInstallLocationCA + InstallPackageIdentityMSIXCA + UninstallPackageIdentityMSIXCA diff --git a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj index db6f6e6392..ae50cdcedb 100644 --- a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj +++ b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj @@ -34,13 +34,8 @@ - $(Platform)\$(Configuration)\MachineSetup\ - $(Platform)\$(Configuration)\UserSetup\ - $(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\MachineSetup\obj\ - $(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\UserSetup\obj\ - - false - true + $(Platform)\$(Configuration)\SetupShared\ + $(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\SetupShared\obj\ true @@ -59,12 +54,14 @@ call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\CmdPal.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\CmdPal.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\ColorPicker.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\ColorPicker.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Core.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Core.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\DscResources.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\DscResources.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\EnvironmentVariables.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\EnvironmentVariables.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\FileExplorerPreview.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\FileExplorerPreview.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\FileLocksmith.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\FileLocksmith.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Hosts.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Hosts.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\ImageResizer.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\ImageResizer.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\KeyboardManager.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\KeyboardManager.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\LightSwitch.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\LightSwitch.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\MouseWithoutBorders.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\MouseWithoutBorders.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\NewPlus.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\NewPlus.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Peek.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Peek.wxs.bk"""" @@ -79,8 +76,7 @@ call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\WinAppSDK.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\WinAppSDK.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\WinUI3Applications.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\WinUI3Applications.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Workspaces.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Workspaces.wxs.bk"""" - if not "$(NormalizedPerUserValue)" == "true" call powershell.exe -NonInteractive -executionpolicy Unrestricted -File ..\PowerToysSetupVNext\generateAllFileComponents.ps1 -platform $(Platform) - if "$(NormalizedPerUserValue)" == "true" call powershell.exe -NonInteractive -executionpolicy Unrestricted -File ..\PowerToysSetupVNext\generateAllFileComponents.ps1 -platform $(Platform) -installscopeperuser $(NormalizedPerUserValue) + call powershell.exe -NonInteractive -executionpolicy Unrestricted -File ..\PowerToysSetupVNext\generateAllFileComponents.ps1 -platform $(Platform) Backing up original files and populating .NET and WPF Runtime dependencies for WiX3 based installer @@ -180,4 +176,4 @@ - \ No newline at end of file + diff --git a/installer/PowerToysSetupVNext/Common.wxi b/installer/PowerToysSetupVNext/Common.wxi index 4bb4f5a1dc..21855a7936 100644 --- a/installer/PowerToysSetupVNext/Common.wxi +++ b/installer/PowerToysSetupVNext/Common.wxi @@ -37,7 +37,7 @@ - + @@ -46,7 +46,7 @@ - + diff --git a/installer/PowerToysSetupVNext/Core.wxs b/installer/PowerToysSetupVNext/Core.wxs index d3f992d82e..a9cf083512 100644 --- a/installer/PowerToysSetupVNext/Core.wxs +++ b/installer/PowerToysSetupVNext/Core.wxs @@ -9,6 +9,25 @@ + + + + + + + + + + + + + + + + + + + @@ -44,16 +63,6 @@ - - - - - - - - - - @@ -101,7 +110,6 @@ - @@ -113,7 +121,11 @@ - + + + + + diff --git a/installer/PowerToysSetupVNext/DscResources.wxs b/installer/PowerToysSetupVNext/DscResources.wxs new file mode 100644 index 0000000000..2c08253229 --- /dev/null +++ b/installer/PowerToysSetupVNext/DscResources.wxs @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/PowerToysSetupVNext/LightSwitch.wxs b/installer/PowerToysSetupVNext/LightSwitch.wxs new file mode 100644 index 0000000000..01f4bc329b --- /dev/null +++ b/installer/PowerToysSetupVNext/LightSwitch.wxs @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/PowerToysSetupVNext/PowerToys.wxs b/installer/PowerToysSetupVNext/PowerToys.wxs index 19906089bf..64f6f35c5e 100644 --- a/installer/PowerToysSetupVNext/PowerToys.wxs +++ b/installer/PowerToysSetupVNext/PowerToys.wxs @@ -28,6 +28,9 @@ + + + @@ -58,6 +61,7 @@ + diff --git a/installer/PowerToysSetupVNext/PowerToysBootstrapperVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysBootstrapperVNext.wixproj index 1a3a4a8cac..6b17b17030 100644 --- a/installer/PowerToysSetupVNext/PowerToysBootstrapperVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysBootstrapperVNext.wixproj @@ -3,7 +3,7 @@ false - Version=$(Version);InstallerSuffix=$(InstallerSuffix) + Version=$(Version) PowerToysVNextBootstrapper @@ -22,12 +22,11 @@ Release x64 arm64 - wix5 - PowerToysSetup-$(Version)-$(InstallerSuffix)-$(Platform) + PowerToysSetup-$(Version)-$(Platform) Bundle True - PowerToysSetup-$(Version)-$(InstallerSuffix)-$(Platform) - PowerToysUserSetup-$(Version)-$(InstallerSuffix)-$(Platform) + PowerToysSetup-$(Version)-$(Platform) + PowerToysUserSetup-$(Version)-$(Platform) $(Platform)\$(Configuration)\MachineSetup $(Platform)\$(Configuration)\UserSetup $(BaseIntermediateOutputPath)$(Platform)\$(Configuration)\MachineSetup diff --git a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index 0cb9118b91..18d6232140 100644 --- a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -4,7 +4,7 @@ false - Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\x64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion);InstallerSuffix=$(InstallerSuffix) @@ -17,7 +17,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil - Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion);InstallerSuffix=$(InstallerSuffix) + Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion) IF NOT DEFINED IsPipeline ( call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=arm64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) SET PTRoot=$(SolutionDir)\.. @@ -35,10 +35,12 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil call move /Y ..\..\..\CmdPal.wxs.bk ..\..\..\CmdPal.wxs call move /Y ..\..\..\ColorPicker.wxs.bk ..\..\..\ColorPicker.wxs call move /Y ..\..\..\Core.wxs.bk ..\..\..\Core.wxs + call move /Y ..\..\..\DscResources.wxs.bk ..\..\..\DscResources.wxs call move /Y ..\..\..\EnvironmentVariables.wxs.bk ..\..\..\EnvironmentVariables.wxs call move /Y ..\..\..\FileExplorerPreview.wxs.bk ..\..\..\FileExplorerPreview.wxs call move /Y ..\..\..\FileLocksmith.wxs.bk ..\..\..\FileLocksmith.wxs call move /Y ..\..\..\Hosts.wxs.bk ..\..\..\Hosts.wxs + call move /Y ..\..\..\LightSwitch.wxs.bk ..\..\..\LightSwitch.wxs call move /Y ..\..\..\ImageResizer.wxs.bk ..\..\..\ImageResizer.wxs call move /Y ..\..\..\KeyboardManager.wxs.bk ..\..\..\KeyboardManager.wxs call move /Y ..\..\..\MouseWithoutBorders.wxs.bk ..\..\..\MouseWithoutBorders.wxs @@ -57,6 +59,12 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil call move /Y ..\..\..\Workspaces.wxs.bk ..\..\..\Workspaces.wxs + + + + false + false + $(DefineConstants);PerUser=true @@ -77,9 +85,8 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil 3.10 {b6e94700-df38-41f6-a3fd-18b69674ab1e} 2.0 - wix5 - PowerToysSetup-$(Version)-$(InstallerSuffix)-$(Platform) - PowerToysUserSetup-$(Version)-$(InstallerSuffix)-$(Platform) + PowerToysSetup-$(Version)-$(Platform) + PowerToysUserSetup-$(Version)-$(Platform) Package True @@ -112,9 +119,11 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil + + diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs index e343897d5d..3e812beb2e 100644 --- a/installer/PowerToysSetupVNext/Product.wxs +++ b/installer/PowerToysSetupVNext/Product.wxs @@ -50,6 +50,7 @@ + @@ -62,6 +63,7 @@ + @@ -69,8 +71,8 @@ - - + + @@ -110,6 +112,7 @@ + @@ -117,8 +120,11 @@ + + + @@ -140,6 +146,7 @@ + + + @@ -192,6 +202,12 @@ + + + + + + @@ -244,6 +260,8 @@ + + diff --git a/installer/PowerToysSetupVNext/Settings.wxs b/installer/PowerToysSetupVNext/Settings.wxs index f9e5312ea7..cf7cf7f727 100644 --- a/installer/PowerToysSetupVNext/Settings.wxs +++ b/installer/PowerToysSetupVNext/Settings.wxs @@ -14,11 +14,16 @@ + + + - + + + @@ -45,6 +50,11 @@ + + + + + @@ -67,6 +77,7 @@ + diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj index dfa43efebe..3972c1b0f7 100644 --- a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj @@ -26,6 +26,7 @@ DynamicLibrary Unicode SilentFilesInUseBAFunction + PowerToysSetupCustomActionsVNext bafunctions.def 10.0 @@ -91,5 +92,31 @@ + + + + _DEBUG;%(PreprocessorDefinitions) + Disabled + MultiThreadedDebug + + + true + + + + + NDEBUG;%(PreprocessorDefinitions) + MaxSpeed + MultiThreaded + true + true + + + true + true + true + + + diff --git a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 index fb63868f93..6724d95170 100644 --- a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 +++ b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 @@ -1,9 +1,7 @@ [CmdletBinding()] Param( [Parameter(Mandatory = $True, Position = 1)] - [string]$platform, - [Parameter(Mandatory = $False, Position = 2)] - [string]$installscopeperuser = "false" + [string]$platform ) Function Generate-FileList() { @@ -77,9 +75,7 @@ Function Generate-FileComponents() { [Parameter(Mandatory = $True, Position = 1)] [string]$fileListName, [Parameter(Mandatory = $True, Position = 2)] - [string]$wxsFilePath, - [Parameter(Mandatory = $True, Position = 3)] - [string]$regroot + [string]$wxsFilePath ) $wxsFile = Get-Content $wxsFilePath; @@ -100,7 +96,7 @@ Function Generate-FileComponents() { $componentDefs += @" - + `r`n "@ @@ -134,190 +130,194 @@ if ($platform -ceq "arm64") { $platform = "ARM64" } -if ($installscopeperuser -eq "true") { - $registryroot = "HKCU" -} else { - $registryroot = "HKLM" -} - #BaseApplications Generate-FileList -fileDepsJson "" -fileListName BaseApplicationsFiles -wxsFilePath $PSScriptRoot\BaseApplications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release" -Generate-FileComponents -fileListName "BaseApplicationsFiles" -wxsFilePath $PSScriptRoot\BaseApplications.wxs -regroot $registryroot +Generate-FileComponents -fileListName "BaseApplicationsFiles" -wxsFilePath $PSScriptRoot\BaseApplications.wxs #WinUI3Applications Generate-FileList -fileDepsJson "" -fileListName WinUI3ApplicationsFiles -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps" -Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs #AdvancedPaste Generate-FileList -fileDepsJson "" -fileListName AdvancedPasteAssetsFiles -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\AdvancedPaste" -Generate-FileComponents -fileListName "AdvancedPasteAssetsFiles" -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs -regroot $registryroot +Generate-FileComponents -fileListName "AdvancedPasteAssetsFiles" -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs #AwakeFiles Generate-FileList -fileDepsJson "" -fileListName AwakeImagesFiles -wxsFilePath $PSScriptRoot\Awake.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Awake" -Generate-FileComponents -fileListName "AwakeImagesFiles" -wxsFilePath $PSScriptRoot\Awake.wxs -regroot $registryroot +Generate-FileComponents -fileListName "AwakeImagesFiles" -wxsFilePath $PSScriptRoot\Awake.wxs #ColorPicker Generate-FileList -fileDepsJson "" -fileListName ColorPickerAssetsFiles -wxsFilePath $PSScriptRoot\ColorPicker.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\ColorPicker" -Generate-FileComponents -fileListName "ColorPickerAssetsFiles" -wxsFilePath $PSScriptRoot\ColorPicker.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ColorPickerAssetsFiles" -wxsFilePath $PSScriptRoot\ColorPicker.wxs #Environment Variables Generate-FileList -fileDepsJson "" -fileListName EnvironmentVariablesAssetsFiles -wxsFilePath $PSScriptRoot\EnvironmentVariables.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\EnvironmentVariables" -Generate-FileComponents -fileListName "EnvironmentVariablesAssetsFiles" -wxsFilePath $PSScriptRoot\EnvironmentVariables.wxs -regroot $registryroot +Generate-FileComponents -fileListName "EnvironmentVariablesAssetsFiles" -wxsFilePath $PSScriptRoot\EnvironmentVariables.wxs #FileExplorerAdd-ons Generate-FileList -fileDepsJson "" -fileListName MonacoPreviewHandlerMonacoAssetsFiles -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Monaco" Generate-FileList -fileDepsJson "" -fileListName MonacoPreviewHandlerCustomLanguagesFiles -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Monaco\customLanguages" -Generate-FileComponents -fileListName "MonacoPreviewHandlerMonacoAssetsFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -regroot $registryroot -Generate-FileComponents -fileListName "MonacoPreviewHandlerCustomLanguagesFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -regroot $registryroot +Generate-FileComponents -fileListName "MonacoPreviewHandlerMonacoAssetsFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs +Generate-FileComponents -fileListName "MonacoPreviewHandlerCustomLanguagesFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs #FileLocksmith Generate-FileList -fileDepsJson "" -fileListName FileLocksmithAssetsFiles -wxsFilePath $PSScriptRoot\FileLocksmith.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\FileLocksmith" -Generate-FileComponents -fileListName "FileLocksmithAssetsFiles" -wxsFilePath $PSScriptRoot\FileLocksmith.wxs -regroot $registryroot +Generate-FileComponents -fileListName "FileLocksmithAssetsFiles" -wxsFilePath $PSScriptRoot\FileLocksmith.wxs #Hosts Generate-FileList -fileDepsJson "" -fileListName HostsAssetsFiles -wxsFilePath $PSScriptRoot\Hosts.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Hosts" -Generate-FileComponents -fileListName "HostsAssetsFiles" -wxsFilePath $PSScriptRoot\Hosts.wxs -regroot $registryroot +Generate-FileComponents -fileListName "HostsAssetsFiles" -wxsFilePath $PSScriptRoot\Hosts.wxs #ImageResizer Generate-FileList -fileDepsJson "" -fileListName ImageResizerAssetsFiles -wxsFilePath $PSScriptRoot\ImageResizer.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\ImageResizer" -Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PSScriptRoot\ImageResizer.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PSScriptRoot\ImageResizer.wxs + +# Light Switch Service +Generate-FileList -fileDepsJson "" -fileListName LightSwitchFiles -wxsFilePath $PSScriptRoot\LightSwitch.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\LightSwitchService" +Generate-FileComponents -fileListName "LightSwitchFiles" -wxsFilePath $PSScriptRoot\LightSwitch.wxs #New+ Generate-FileList -fileDepsJson "" -fileListName NewPlusAssetsFiles -wxsFilePath $PSScriptRoot\NewPlus.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\NewPlus" -Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs -regroot $registryroot +Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs #Peek Generate-FileList -fileDepsJson "" -fileListName PeekAssetsFiles -wxsFilePath $PSScriptRoot\Peek.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Peek\" -Generate-FileComponents -fileListName "PeekAssetsFiles" -wxsFilePath $PSScriptRoot\Peek.wxs -regroot $registryroot +Generate-FileComponents -fileListName "PeekAssetsFiles" -wxsFilePath $PSScriptRoot\Peek.wxs #PowerRename Generate-FileList -fileDepsJson "" -fileListName PowerRenameAssetsFiles -wxsFilePath $PSScriptRoot\PowerRename.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerRename\" -Generate-FileComponents -fileListName "PowerRenameAssetsFiles" -wxsFilePath $PSScriptRoot\PowerRename.wxs -regroot $registryroot +Generate-FileComponents -fileListName "PowerRenameAssetsFiles" -wxsFilePath $PSScriptRoot\PowerRename.wxs #RegistryPreview Generate-FileList -fileDepsJson "" -fileListName RegistryPreviewAssetsFiles -wxsFilePath $PSScriptRoot\RegistryPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\RegistryPreview\" -Generate-FileComponents -fileListName "RegistryPreviewAssetsFiles" -wxsFilePath $PSScriptRoot\RegistryPreview.wxs -regroot $registryroot +Generate-FileComponents -fileListName "RegistryPreviewAssetsFiles" -wxsFilePath $PSScriptRoot\RegistryPreview.wxs #Run Generate-FileList -fileDepsJson "" -fileListName launcherImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\PowerLauncher" -Generate-FileComponents -fileListName "launcherImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "launcherImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ## Plugins ###Calculator Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Calculator\Microsoft.PowerToys.Run.Plugin.Calculator.deps.json" -fileListName calcComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName calcImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Calculator\Images" -Generate-FileComponents -fileListName "calcComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "calcImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "calcComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "calcImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Folder Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Folder\Microsoft.Plugin.Folder.deps.json" -fileListName FolderComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName FolderImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Folder\Images" -Generate-FileComponents -fileListName "FolderComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "FolderImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "FolderComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "FolderImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Program Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Program\Microsoft.Plugin.Program.deps.json" -fileListName ProgramComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName ProgramImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Program\Images" -Generate-FileComponents -fileListName "ProgramComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ProgramImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ProgramComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "ProgramImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Shell Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Shell\Microsoft.Plugin.Shell.deps.json" -fileListName ShellComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName ShellImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Shell\Images" -Generate-FileComponents -fileListName "ShellComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ShellImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ShellComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "ShellImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Indexer Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Indexer\Microsoft.Plugin.Indexer.deps.json" -fileListName IndexerComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName IndexerImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Indexer\Images" -Generate-FileComponents -fileListName "IndexerComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "IndexerImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "IndexerComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "IndexerImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###UnitConverter Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\UnitConverter\Community.PowerToys.Run.Plugin.UnitConverter.deps.json" -fileListName UnitConvCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName UnitConvImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\UnitConverter\Images" -Generate-FileComponents -fileListName "UnitConvCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "UnitConvImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "UnitConvCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "UnitConvImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###WebSearch Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WebSearch\Community.PowerToys.Run.Plugin.WebSearch.deps.json" -fileListName WebSrchCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName WebSrchImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WebSearch\Images" -Generate-FileComponents -fileListName "WebSrchCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WebSrchImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WebSrchCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "WebSrchImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###History Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\History\Microsoft.PowerToys.Run.Plugin.History.deps.json" -fileListName HistoryPluginComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName HistoryPluginImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\History\Images" -Generate-FileComponents -fileListName "HistoryPluginComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "HistoryPluginImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "HistoryPluginComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "HistoryPluginImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Uri Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Uri\Microsoft.Plugin.Uri.deps.json" -fileListName UriComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName UriImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Uri\Images" -Generate-FileComponents -fileListName "UriComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "UriImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "UriComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "UriImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###VSCodeWorkspaces Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\VSCodeWorkspaces\Community.PowerToys.Run.Plugin.VSCodeWorkspaces.deps.json" -fileListName VSCWrkCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName VSCWrkImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\VSCodeWorkspaces\Images" -Generate-FileComponents -fileListName "VSCWrkCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "VSCWrkImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "VSCWrkCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "VSCWrkImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###WindowWalker Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowWalker\Microsoft.Plugin.WindowWalker.deps.json" -fileListName WindowWlkrCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName WindowWlkrImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowWalker\Images" -Generate-FileComponents -fileListName "WindowWlkrCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WindowWlkrImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WindowWlkrCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "WindowWlkrImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###OneNote Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\OneNote\Microsoft.PowerToys.Run.Plugin.OneNote.deps.json" -fileListName OneNoteComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName OneNoteImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\OneNote\Images" -Generate-FileComponents -fileListName "OneNoteComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "OneNoteImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "OneNoteComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "OneNoteImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Registry Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Registry\Microsoft.PowerToys.Run.Plugin.Registry.deps.json" -fileListName RegistryComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName RegistryImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Registry\Images" -Generate-FileComponents -fileListName "RegistryComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "RegistryImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "RegistryComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "RegistryImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Service Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Service\Microsoft.PowerToys.Run.Plugin.Service.deps.json" -fileListName ServiceComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName ServiceImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Service\Images" -Generate-FileComponents -fileListName "ServiceComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ServiceImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ServiceComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "ServiceImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###System Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\System\Microsoft.PowerToys.Run.Plugin.System.deps.json" -fileListName SystemComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName SystemImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\System\Images" -Generate-FileComponents -fileListName "SystemComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SystemImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "SystemComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "SystemImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###TimeDate Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\TimeDate\Microsoft.PowerToys.Run.Plugin.TimeDate.deps.json" -fileListName TimeDateComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName TimeDateImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\TimeDate\Images" -Generate-FileComponents -fileListName "TimeDateComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "TimeDateImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "TimeDateComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "TimeDateImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###WindowsSettings Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsSettings\Microsoft.PowerToys.Run.Plugin.WindowsSettings.deps.json" -fileListName WinSetCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName WinSetImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsSettings\Images" -Generate-FileComponents -fileListName "WinSetCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WinSetImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WinSetCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "WinSetImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###WindowsTerminal Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsTerminal\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.deps.json" -fileListName WinTermCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName WinTermImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsTerminal\Images" -Generate-FileComponents -fileListName "WinTermCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WinTermImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WinTermCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "WinTermImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###PowerToys Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\PowerToys\Microsoft.PowerToys.Run.Plugin.PowerToys.deps.json" -fileListName PowerToysCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName PowerToysImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\PowerToys\Images" -Generate-FileComponents -fileListName "PowerToysCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "PowerToysImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "PowerToysCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "PowerToysImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###ValueGenerator Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\ValueGenerator\Community.PowerToys.Run.Plugin.ValueGenerator.deps.json" -fileListName ValueGeneratorCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName ValueGeneratorImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\ValueGenerator\Images" -Generate-FileComponents -fileListName "ValueGeneratorCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ValueGeneratorImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ValueGeneratorCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "ValueGeneratorImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs ## Plugins #ShortcutGuide Generate-FileList -fileDepsJson "" -fileListName ShortcutGuideSvgFiles -wxsFilePath $PSScriptRoot\ShortcutGuide.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\ShortcutGuide\" -Generate-FileComponents -fileListName "ShortcutGuideSvgFiles" -wxsFilePath $PSScriptRoot\ShortcutGuide.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ShortcutGuideSvgFiles" -wxsFilePath $PSScriptRoot\ShortcutGuide.wxs #Settings Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\OOBE\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsFluentIconsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\" -Generate-FileComponents -fileListName "SettingsV2AssetsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2AssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2OOBEAssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2OOBEAssetsFluentIconsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot +Generate-FileList -fileDepsJson "" -fileListName SettingsV2IconsModelsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\Models\" +Generate-FileComponents -fileListName "SettingsV2AssetsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2AssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2OOBEAssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2OOBEAssetsFluentIconsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2IconsModelsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs #Workspaces Generate-FileList -fileDepsJson "" -fileListName WorkspacesImagesComponentFiles -wxsFilePath $PSScriptRoot\Workspaces.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Workspaces\" -Generate-FileComponents -fileListName "WorkspacesImagesComponentFiles" -wxsFilePath $PSScriptRoot\Workspaces.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WorkspacesImagesComponentFiles" -wxsFilePath $PSScriptRoot\Workspaces.wxs + +#DSC Resources - JSON manifest files in DSCModules subfolder +Generate-FileList -fileDepsJson "" -fileListName DscJsonFiles -wxsFilePath $PSScriptRoot\DscResources.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\DSCModules\" +Generate-FileComponents -fileListName "DscJsonFiles" -wxsFilePath $PSScriptRoot\DscResources.wxs diff --git a/installer/wix.props b/installer/wix.props deleted file mode 100644 index d33624a8c7..0000000000 --- a/installer/wix.props +++ /dev/null @@ -1,14 +0,0 @@ - - - - C:\Program Files (x86)\WiX Toolset v3.14\bin\ - $(WixInstallPath)\ - - $(WixInstallPath)\..\wix.targets - $(WixInstallPath)\..\lux.targets - - $(WixInstallPath)\WixTasks.dll - $(WixInstallPath)\..\sdk\ - $(WixSdkPath)\..\wix.ca.targets - - \ No newline at end of file diff --git a/nuget.config b/nuget.config index 51f9b3b3f7..6b8d13a023 100644 --- a/nuget.config +++ b/nuget.config @@ -9,4 +9,4 @@ - + \ No newline at end of file diff --git a/src/CmdPalVersion.props b/src/CmdPalVersion.props index 2be9bc69d4..c3c5d7b608 100644 --- a/src/CmdPalVersion.props +++ b/src/CmdPalVersion.props @@ -2,7 +2,10 @@ $(XES_APPXMANIFESTVERSION) + + 0.0.1.0 + Local diff --git a/src/PackageIdentity/AppxManifest.xml b/src/PackageIdentity/AppxManifest.xml new file mode 100644 index 0000000000..822daae8bc --- /dev/null +++ b/src/PackageIdentity/AppxManifest.xml @@ -0,0 +1,70 @@ + + + + + + + PowerToys.SparseApp + PowerToys + Images\StoreLogo.png + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/PackageIdentity/BuildSparsePackage.cmd b/src/PackageIdentity/BuildSparsePackage.cmd new file mode 100644 index 0000000000..71a4a6a77c --- /dev/null +++ b/src/PackageIdentity/BuildSparsePackage.cmd @@ -0,0 +1,6 @@ +@echo off +REM Wrapper to invoke PowerToys sparse package build script. +REM Pass through all arguments (e.g. Platform=arm64 Configuration=Debug -Clean) + +powershell -ExecutionPolicy Bypass -NoLogo -NoProfile -File "%~dp0\BuildSparsePackage.ps1" %* +exit /b %ERRORLEVEL% diff --git a/src/PackageIdentity/BuildSparsePackage.ps1 b/src/PackageIdentity/BuildSparsePackage.ps1 new file mode 100644 index 0000000000..1e341c24f5 --- /dev/null +++ b/src/PackageIdentity/BuildSparsePackage.ps1 @@ -0,0 +1,422 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +Param( + [Parameter(Mandatory=$false)] + [ValidateSet("arm64", "x64")] + [string]$Platform = "x64", + + [Parameter(Mandatory=$false)] + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + + [switch]$Clean, + [switch]$ForceCert, + [switch]$NoSign, + [switch]$CIBuild +) + +# PowerToys sparse packaging helper. +# Generates a sparse MSIX (no payload) that grants package identity to selected Win32 components. +# Multiple applications (PowerOCR, Settings UI, etc.) can share this single sparse identity. + +$ErrorActionPreference = 'Stop' + +$isCIBuild = $false +if ($CIBuild.IsPresent) { + $isCIBuild = $true +} elseif ($env:CIBuild) { + $isCIBuild = $env:CIBuild -ieq 'true' +} + +$currentPublisherHint = $script:Config.CertSubject + +# Configuration constants - centralized management +$script:Config = @{ + IdentityName = "Microsoft.PowerToys.SparseApp" + SparseMsixName = "PowerToysSparse.msix" + CertPrefix = "PowerToysSparse" + CertSubject = 'CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US' + CertValidMonths = 12 +} + +#region Helper Functions + +function Find-WindowsSDKTool { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$ToolName, + + [Parameter(Mandatory=$false)] + [string]$Architecture = "x64" + ) + + # Simple fallback: check common Windows SDK locations + $commonPaths = @( + "${env:ProgramFiles}\Windows Kits\10\bin\*\$Architecture\$ToolName", + "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\$Architecture\$ToolName", + "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x86\$ToolName" # SignTool fallback + ) + + foreach ($pattern in $commonPaths) { + $found = Get-ChildItem $pattern -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($found) { + Write-BuildLog "Found $ToolName at: $($found.FullName)" -Level Info + return $found.FullName + } + } + + throw "$ToolName not found. Please ensure Windows SDK is installed." +} + +function Test-CertificateValidity { + param([string]$ThumbprintFile) + + if (-not (Test-Path $ThumbprintFile)) { return $false } + + try { + $thumb = (Get-Content $ThumbprintFile -Raw).Trim() + if (-not $thumb) { return $false } + $cert = Get-Item "cert:\CurrentUser\My\$thumb" -ErrorAction Stop + return $cert.HasPrivateKey -and $cert.NotAfter -gt (Get-Date) + } catch { + return $false + } +} + +function Write-BuildLog { + param([string]$Message, [string]$Level = "Info") + + $colors = @{ Error = "Red"; Warning = "Yellow"; Success = "Green"; Info = "Cyan" } + $color = if ($colors.ContainsKey($Level)) { $colors[$Level] } else { "White" } + + Write-Host "[$(Get-Date -f 'HH:mm:ss')] $Message" -ForegroundColor $color +} + +function Stop-FileProcesses { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$FilePath + ) + + # This function is kept for compatibility but simplified since + # the staging directory approach resolves the file lock issues + Write-Verbose "File process check for: $FilePath" +} + +#endregion + +# Environment diagnostics for troubleshooting +Write-BuildLog "Starting PackageIdentity build process..." -Level Info +Write-BuildLog "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info +try { + $execPolicy = Get-ExecutionPolicy + Write-BuildLog "Execution Policy: $execPolicy" -Level Info +} catch { + Write-BuildLog "Execution Policy: Unable to determine (MSBuild environment)" -Level Info +} +Write-BuildLog "Current User: $env:USERNAME" -Level Info +Write-BuildLog "Build Platform: $Platform, Configuration: $Configuration" -Level Info + +# Check for Visual Studio environment +if ($env:VSINSTALLDIR) { + Write-BuildLog "Running in Visual Studio environment: $env:VSINSTALLDIR" -Level Info +} + +# Ensure certificate provider is available +try { + # Force load certificate provider for MSBuild environment + if (-not (Get-PSProvider -PSProvider Certificate -ErrorAction SilentlyContinue)) { + Write-BuildLog "Loading certificate provider..." -Level Warning + Import-Module Microsoft.PowerShell.Security -Force + } + if (-not (Test-Path 'Cert:\CurrentUser')) { + Write-BuildLog "Certificate drive not available, attempting to initialize..." -Level Warning + Import-Module PKI -ErrorAction SilentlyContinue + # Try to access the certificate store to force initialization + Get-ChildItem "Cert:\CurrentUser\My" -ErrorAction SilentlyContinue | Out-Null + } +} catch { + Write-BuildLog ("Note: Certificate provider setup may need manual configuration: {0}" -f $_) -Level Warning +} + +# Project root folder (now set to current script folder for local builds) +$ProjectRoot = $PSScriptRoot +$UserFolder = Join-Path $ProjectRoot '.user' +if (-not (Test-Path $UserFolder)) { New-Item -ItemType Directory -Path $UserFolder | Out-Null } + +# Certificate file paths using configuration +$prefix = $script:Config.CertPrefix +$CertThumbFile, $CertCerFile = @('.thumbprint', '.cer') | + ForEach-Object { Join-Path $UserFolder "$prefix.certificate.sample$_" } + +# Clean option: remove bin/obj and uninstall existing sparse package if present +if ($Clean) { + Write-BuildLog "Cleaning build artifacts..." -Level Info + 'bin','obj' | ForEach-Object { + $target = Join-Path $ProjectRoot $_ + if (Test-Path $target) { Remove-Item $target -Recurse -Force } + } + Write-BuildLog "Attempting to remove existing sparse package (best effort)" -Level Info + try { Get-AppxPackage -Name $script:Config.IdentityName | Remove-AppxPackage } catch {} +} + +# Force certificate regeneration if requested +if ($ForceCert -and (Test-Path $UserFolder)) { + Write-BuildLog "ForceCert specified: removing existing certificate artifacts..." -Level Warning + Remove-Item $UserFolder -Recurse -Force + New-Item -ItemType Directory -Path $UserFolder | Out-Null +} + +# Ensure dev cert (development only; not for production use) - skip if NoSign specified +$needNewCert = -not $NoSign -and (-not (Test-Path $CertThumbFile) -or $ForceCert -or -not (Test-CertificateValidity -ThumbprintFile $CertThumbFile)) + +if ($needNewCert) { + Write-BuildLog "Generating development certificate (prefix=$($script:Config.CertPrefix))..." -Level Info + + # Clear stale files in the certificate cache + if (Test-Path $UserFolder) { + Get-ChildItem -Path $UserFolder | ForEach-Object { + if ($_.PSIsContainer) { + Remove-Item $_.FullName -Recurse -Force + } else { + Remove-Item $_.FullName -Force + } + } + } + if (-not (Test-Path $UserFolder)) { + New-Item -ItemType Directory -Path $UserFolder | Out-Null + } + + $now = Get-Date + $expiration = $now.AddMonths($script:Config.CertValidMonths) + # Subject MUST match inside AppxManifest.xml + $friendlyName = "PowerToys Dev Sparse Cert Create=$now" + $keyFriendly = "PowerToys Dev Sparse Key Create=$now" + + $certStore = 'cert:\CurrentUser\My' + $ekuOid = '2.5.29.37' + $ekuValue = '1.3.6.1.5.5.7.3.3,1.3.6.1.4.1.311.10.3.13' + $eku = "$ekuOid={text}$ekuValue" + + $cert = New-SelfSignedCertificate -CertStoreLocation $certStore ` + -NotAfter $expiration ` + -Subject $script:Config.CertSubject ` + -FriendlyName $friendlyName ` + -KeyFriendlyName $keyFriendly ` + -KeyDescription $keyFriendly ` + -TextExtension $eku + + # Export certificate files + Set-Content -Path $CertThumbFile -Value $cert.Thumbprint -Force + Export-Certificate -Cert $cert -FilePath $CertCerFile -Force | Out-Null +} + +# Determine output directory - using PowerToys standard structure +# Navigate to PowerToys root (two levels up from src/PackageIdentity) +$PowerToysRoot = Split-Path (Split-Path $ProjectRoot -Parent) -Parent +$outDir = Join-Path $PowerToysRoot "$Platform\$Configuration" + +if (-not (Test-Path $outDir)) { + Write-BuildLog "Creating output directory: $outDir" -Level Info + New-Item -ItemType Directory -Path $outDir -Force | Out-Null +} + +# PackageIdentity folder (this script location) containing the sparse manifest and assets +$sparseDir = $PSScriptRoot +$manifestPath = Join-Path $sparseDir 'AppxManifest.xml' +if (-not (Test-Path $manifestPath)) { throw "Missing AppxManifest.xml in PackageIdentity folder: $manifestPath" } + +$versionPropsPath = Join-Path $PowerToysRoot 'src\Version.props' +$targetManifestVersion = $null +$versionCandidate = $null +if (Test-Path $versionPropsPath) { + try { + [xml]$propsXml = Get-Content -Path $versionPropsPath -Raw + $versionCandidate = $propsXml.Project.PropertyGroup.Version + } catch { + Write-BuildLog ("Unable to read version from {0}: {1}" -f $versionPropsPath, $_) -Level Warning + } +} else { + Write-BuildLog "Version.props not found at $versionPropsPath; manifest version will remain unchanged." -Level Warning +} + +if ($versionCandidate) { + $targetManifestVersion = $versionCandidate.Trim() + if (($targetManifestVersion -split '\.').Count -lt 4) { + $targetManifestVersion = "$targetManifestVersion.0" + } + Write-BuildLog "Using sparse package version from Version.props: $targetManifestVersion" -Level Info +} else { + Write-BuildLog "No version value provided; manifest version will remain unchanged." -Level Info +} + +# Find MakeAppx.exe from Windows SDK +try { + $hostSdkArchitecture = if ([System.Environment]::Is64BitProcess) { 'x64' } else { 'x86' } + $makeAppxPath = Find-WindowsSDKTool -ToolName "makeappx.exe" -Architecture $hostSdkArchitecture +} catch { + Write-Error "MakeAppx.exe not found. Please ensure Windows SDK is installed." + exit 1 +} + +# Pack sparse MSIX from PackageIdentity folder +$msixPath = Join-Path $outDir $script:Config.SparseMsixName + +# Clean up existing MSIX file +if (Test-Path $msixPath) { + Write-BuildLog "Removing existing MSIX file..." -Level Info + try { + Remove-Item $msixPath -Force -ErrorAction Stop + Write-BuildLog "Successfully removed existing MSIX file" -Level Success + } catch { + Write-BuildLog ("Warning: Could not remove existing MSIX file: {0}" -f $_) -Level Warning + } +} + +# Create a clean staging directory to avoid file lock issues +$stagingDir = Join-Path $outDir "staging" +if (Test-Path $stagingDir) { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue +} +New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null + +try { + Write-BuildLog "Creating clean staging directory for packaging..." -Level Info + + # Copy only essential files to staging directory to avoid file locks + $essentialFiles = @( + "AppxManifest.xml" + "Images\*" + ) + + foreach ($filePattern in $essentialFiles) { + $sourcePath = Join-Path $sparseDir $filePattern + $relativePath = $filePattern + + if ($filePattern.Contains('\')) { + $targetDir = Join-Path $stagingDir (Split-Path $relativePath -Parent) + if (-not (Test-Path $targetDir)) { + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + } + } + + if ($filePattern.EndsWith('\*')) { + # Copy directory contents + $sourceDir = $sourcePath.TrimEnd('\*') + $targetDir = Join-Path $stagingDir (Split-Path $relativePath.TrimEnd('\*') -Parent) + if (Test-Path $sourceDir) { + Copy-Item -Path "$sourceDir\*" -Destination $targetDir -Force -ErrorAction SilentlyContinue + } + } else { + # Copy single file + $targetPath = Join-Path $stagingDir $relativePath + if (Test-Path $sourcePath) { + Copy-Item -Path $sourcePath -Destination $targetPath -Force -ErrorAction SilentlyContinue + } + } + } + + # Ensure publisher matches the dev certificate for local builds + $manifestStagingPath = Join-Path $stagingDir 'AppxManifest.xml' + $shouldUseDevPublisher = -not $isCIBuild + if (Test-Path $manifestStagingPath) { + try { + [xml]$manifestXml = Get-Content -Path $manifestStagingPath -Raw + $identityNode = $manifestXml.Package.Identity + $manifestChanged = $false + if ($identityNode) { + $currentPublisherHint = $identityNode.Publisher + } + + if ($identityNode) { + if ($targetManifestVersion -and $identityNode.Version -ne $targetManifestVersion) { + Write-BuildLog "Updating manifest version to $targetManifestVersion" -Level Info + $identityNode.SetAttribute('Version', $targetManifestVersion) + $manifestChanged = $true + } + + if ($shouldUseDevPublisher -and $identityNode.Publisher -ne $script:Config.CertSubject) { + Write-BuildLog "Updating manifest publisher for local build" -Level Warning + $identityNode.SetAttribute('Publisher', $script:Config.CertSubject) + $manifestChanged = $true + } + $currentPublisherHint = $identityNode.Publisher + } + + if ($manifestChanged) { + $manifestXml.Save($manifestStagingPath) + } + } catch { + Write-BuildLog ("Unable to adjust manifest metadata: {0}" -f $_) -Level Warning + } + } + + Write-BuildLog "Staging directory prepared with essential files only" -Level Success + + # Pack MSIX using staging directory + Write-BuildLog "Packing sparse MSIX ($($script:Config.SparseMsixName)) from staging -> $msixPath" -Level Info + + & $makeAppxPath pack /d $stagingDir /p $msixPath /nv /o + + if ($LASTEXITCODE -eq 0 -and (Test-Path $msixPath)) { + Write-BuildLog "MSIX packaging completed successfully" -Level Success + } else { + Write-BuildLog "MakeAppx failed with exit code $LASTEXITCODE" -Level Error + exit 1 + } +} finally { + # Clean up staging directory + if (Test-Path $stagingDir) { + try { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + Write-BuildLog "Cleaned up staging directory" -Level Info + } catch { + Write-BuildLog ("Warning: Could not clean up staging directory: {0}" -f $_) -Level Warning + } + } +} + +# Sign package (skip if NoSign specified for CI scenarios) +if ($NoSign) { + Write-BuildLog "Skipping signing (NoSign specified for CI build)" -Level Warning +} else { + # Use certificate thumbprint for signing (safer, no password) + $certThumbprint = (Get-Content -Path $CertThumbFile -Raw).Trim() + try { + $signToolPath = Find-WindowsSDKTool -ToolName "signtool.exe" + } catch { + Write-Error "SignTool.exe not found. Please ensure Windows SDK is installed." + exit 1 + } + Write-BuildLog "Signing sparse MSIX using cert thumbprint $certThumbprint..." -Level Info + & $signToolPath sign /fd SHA256 /sha1 $certThumbprint $msixPath + if ($LASTEXITCODE -ne 0) { + Write-Warning "SignTool failed (exit $LASTEXITCODE). Ensure the certificate is in CurrentUser\\My and try -ForceCert if needed." + exit $LASTEXITCODE + } +} + +$publisherHintFile = Join-Path $UserFolder "$($script:Config.CertPrefix).publisher.txt" +try { + Set-Content -Path $publisherHintFile -Value $currentPublisherHint -Force -NoNewline +} catch { + Write-BuildLog ("Unable to write publisher hint: {0}" -f $_) -Level Warning +} + +Write-BuildLog "`nPackage created: $msixPath" -Level Success + +if ($NoSign) { + Write-BuildLog "UNSIGNED package created for CI build. Sign before deployment." -Level Warning +} else { + Write-BuildLog "Install the dev certificate (once): $CertCerFile" -Level Info + Write-BuildLog "Identity Name: $($script:Config.IdentityName)" -Level Info +} + +Write-BuildLog "Register sparse package:" -Level Info +Write-BuildLog " Add-AppxPackage -Path `"$msixPath`" -ExternalLocation `"$outDir`"" -Level Warning +Write-BuildLog "(If already installed and you changed manifest only): Add-AppxPackage -Register `"$manifestPath`" -ExternalLocation `"$outDir`" -ForceApplicationShutdown" -Level Warning diff --git a/src/PackageIdentity/Check-ProcessIdentity.ps1 b/src/PackageIdentity/Check-ProcessIdentity.ps1 new file mode 100644 index 0000000000..767afe542f --- /dev/null +++ b/src/PackageIdentity/Check-ProcessIdentity.ps1 @@ -0,0 +1,43 @@ +<# +.SYNOPSIS + Determine whether a given process (by PID) runs with an MSIX/UWP package identity. +.DESCRIPTION + Calls the Windows API GetPackageFullName to check if the target process executes under an MSIX/Sparse App/UWP package identity. + Returns the package full name when identity is present, or "No package identity" otherwise. +.PARAMETER ProcessId + The process ID to inspect. +.EXAMPLE + .\Check-ProcessIdentity.ps1 -pid 12345 +#> +param( + [Parameter(Mandatory=$true)] + [int]$ProcessId +) + +Add-Type -TypeDefinition @' +using System; +using System.Text; +using System.Runtime.InteropServices; +public class P { + [DllImport("kernel32.dll", SetLastError=true)] + public static extern IntPtr OpenProcess(uint a, bool b, int p); + [DllImport("kernel32.dll", SetLastError=true)] + public static extern bool CloseHandle(IntPtr h); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern int GetPackageFullName(IntPtr h, ref int l, StringBuilder b); + public static string G(int pid) { + IntPtr h = OpenProcess(0x1000, false, pid); + if (h == IntPtr.Zero) return "Failed to open process"; + int len = 0; + GetPackageFullName(h, ref len, null); + if (len == 0) { CloseHandle(h); return "No package identity"; } + var sb = new StringBuilder(len); + int r = GetPackageFullName(h, ref len, sb); + CloseHandle(h); + return r == 0 ? sb.ToString() : "Error:" + r; + } +} +'@ + +$result = [P]::G($ProcessId) +Write-Output $result diff --git a/src/PackageIdentity/Images/Square150x150Logo.png b/src/PackageIdentity/Images/Square150x150Logo.png new file mode 100644 index 0000000000..01a45755d7 Binary files /dev/null and b/src/PackageIdentity/Images/Square150x150Logo.png differ diff --git a/src/PackageIdentity/Images/Square44x44Logo.png b/src/PackageIdentity/Images/Square44x44Logo.png new file mode 100644 index 0000000000..01a45755d7 Binary files /dev/null and b/src/PackageIdentity/Images/Square44x44Logo.png differ diff --git a/src/PackageIdentity/Images/StoreLogo.png b/src/PackageIdentity/Images/StoreLogo.png new file mode 100644 index 0000000000..01a45755d7 Binary files /dev/null and b/src/PackageIdentity/Images/StoreLogo.png differ diff --git a/src/PackageIdentity/PackageIdentity.vcxproj b/src/PackageIdentity/PackageIdentity.vcxproj new file mode 100644 index 0000000000..f8d34f5650 --- /dev/null +++ b/src/PackageIdentity/PackageIdentity.vcxproj @@ -0,0 +1,120 @@ + + + + + + true + true + + + + + + + -NoSign + + -CIBuild + + + + + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + + 15.0 + Win32Proj + {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D} + PackageIdentity + PackageIdentity + false + + + + + + Utility + true + v143 + + + + Utility + false + v143 + true + + + + Utility + true + v143 + + + + Utility + false + v143 + true + + + + + + + + + + + + + + + + + + + + + + + + + + + Images + + + Images + + + Images + + + + + + + + + \ No newline at end of file diff --git a/src/PackageIdentity/PackageIdentity.vcxproj.filters b/src/PackageIdentity/PackageIdentity.vcxproj.filters new file mode 100644 index 0000000000..608c80f2b9 --- /dev/null +++ b/src/PackageIdentity/PackageIdentity.vcxproj.filters @@ -0,0 +1,25 @@ + + + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + png;jpg;jpeg;gif;bmp;ico + + + + + + + + + + Images + + + Images + + + Images + + + \ No newline at end of file diff --git a/src/PackageIdentity/readme.md b/src/PackageIdentity/readme.md new file mode 100644 index 0000000000..2af2bbb26d --- /dev/null +++ b/src/PackageIdentity/readme.md @@ -0,0 +1,90 @@ +# PowerToys sparse package identity + +This document describes how to build, sign, register, and consume the shared sparse MSIX package that grants package identity to select Win32 components of PowerToys. + +## Package overview + +The sparse package lives under `src/PackageIdentity`. It produces a payload-free MSIX whose `Identity` matches `Microsoft.PowerToys.SparseApp`. The manifest contains one entry per Win32 surface that should run with identity (for example Settings, PowerOCR, Image Resizer). + +> The MSIX contains only metadata. When the package is registered you must point `-ExternalLocation` to the output folder that hosts the Win32 binaries (for example `x64\Release`). + +## Building the sparse package locally + +Two options are available: + +- Build the utility project from Visual Studio: `PackageIdentity.vcxproj` defines a `GenerateSparsePackage` target that runs before `PrepareForBuild` and invokes the helper script automatically. +- Invoke the helper script directly from PowerShell: + +```powershell +$repoRoot = "C:/git/PowerToys" +pwsh "$repoRoot/src/PackageIdentity/BuildSparsePackage.ps1" -Platform x64 -Configuration Release +``` + +Supported switches: + +- `-Clean` removes previous `bin`/`obj` outputs and uninstalls existing installation. +- `-ForceCert` regenerates the local dev certificate (.pfx/.cer/.pwd/.thumbprint) under `src/PackageIdentity/.user`. +- `-NoSign` skips signing. The MSIX still builds but must be signed before deployment. +- `-CIBuild` (or setting `$env:CIBuild = 'true'`) keeps the manifest publisher intact and skips the local cert substitution. + +The script determines the proper `makeappx.exe` for the host build machine (x64 on typical developer boxes) and creates `PowerToysSparse.msix` in `{repo}\\`. + +> After packaging finishes, the helper also emits `src/PackageIdentity/.user/PowerToysSparse.publisher.txt`. This file mirrors the publisher string Windows will see once the sparse package is registered, which downstream projects can read to stay in sync when generating their own manifests. + +## Local signing basics + +When `-NoSign` is not used the script generates (or reuses) a development certificate and signs the package via `signtool.exe`: + +1. Artifacts are stored in `src/PackageIdentity/.user/PowerToysSparse.certificate.sample.*` (`.cer` and `.thumbprint`). +2. Install the `.cer` into `CurrentUser` → `TrustedPeople` (and `TrustedRoot`, if necessary) so Windows trusts the signature: + + ```powershell + $repoRoot = "C:/git/PowerToys" + Import-Certificate -FilePath "$repoRoot/src/PackageIdentity/.user/PowerToysSparse.certificate.sample.cer" -CertStoreLocation Cert:\CurrentUser\TrustedPeople + ``` + +3. The private key stays in the current user's personal certificate store. + +## Registering or unregistering the package + +After `PowerToysSparse.msix` is generated: + +```powershell +# First time registration +$repoRoot = "C:/git/PowerToys" +$outputRoot = Join-Path $repoRoot "x64/Release" +Add-AppxPackage -Path (Join-Path $outputRoot "PowerToysSparse.msix") -ExternalLocation $outputRoot + +# Re-register after manifest tweaks only +Add-AppxPackage -Register (Join-Path $repoRoot "src/PackageIdentity/AppxManifest.xml") -ExternalLocation $outputRoot -ForceApplicationShutdown + +# Remove the sparse identity +Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Remove-AppxPackage +``` + +`-ExternalLocation` should match the output folder that contains the Win32 executables declared in the manifest. Re-run registration whenever the manifest or executable layout changes. + +## CI-specific guidance + +- Pass `-CIBuild` to `BuildSparsePackage.ps1` (or build with `msbuild PackageIdentity.vcxproj /p:CIBuild=true`). This prevents the script from rewriting the manifest publisher to the local dev certificate subject. +- The project automatically adds `-NoSign` only when `$(CIBuild)` is `true`. Local Debug and Release builds are signed with the development certificate. +- Make sure the agent trusts whichever certificate signs the package. If the package remains unsigned (`-NoSign`) it cannot be installed on test machines until it is signed. + +## Consuming the identity from other components + +1. Add a new `` entry inside `src/PackageIdentity/AppxManifest.xml`. Use a unique `Id` (for example `PowerToys.MyModuleUI`) and set `Executable` to the Win32 binary relative to the `-ExternalLocation` root. +2. Ensure the binary is copied into the platform/configuration output folder (`x64\Release`, `ARM64\Debug`, etc.) so the sparse package can locate it. +3. Embed a sparse identity manifest in the Win32 binary so it binds to the MSIX identity at runtime. The manifest must declare an `` element with `packageName="Microsoft.PowerToys.SparseApp"`, `applicationId` matching the ``, and a `publisher` that matches the sparse package. Keep the manifest’s publisher in sync with `src/PackageIdentity/.user/PowerToysSparse.publisher.txt` (emitted by `BuildSparsePackage.ps1`). See `src/modules/imageresizer/ui/ImageResizerUI.csproj` for an example that points `ApplicationManifest` to `ImageResizerUI.dev.manifest` for local builds and switches to `ImageResizerUI.prod.manifest` when `$(CIBuild)` is `true`. +4. Register or re-register the sparse package so Windows learns about the new application Id. +5. To launch the Win32 surface with identity, use the `shell:AppsFolder` activation form (for example: `shell:AppsFolder\Microsoft.PowerToys.SparseApp_!PowerToys.MyModuleUI`) or activate it via `IApplicationActivationManager::ActivateApplication` using the same AppUserModelID. + + - For locally built packages, resolve the `` with `Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Select-Object -ExpandProperty PackageFamilyName`. + - Store-distributed builds use `Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe`. Local developer builds created by this script typically use a different family name derived from the dev certificate. + +6. Context menu handlers or other launchers should fall back to the unpackaged executable path for environments where the sparse package is not present. + +## Troubleshooting tips + +- `Program 'makeappx.exe' failed to run`: make sure you are running an x64 PowerShell host. The script now chooses the appropriate makeappx automatically; update your repo if the log still points to an ARM64 binary. +- `HRESULT 0x800B0109 (trust failure)`: install the development certificate into both `TrustedPeople` and `TrustedRoot` stores for the current user. +- Stale registration: remove the package with `Remove-AppxPackage` and re-run the script with `-Clean` to rebuild from scratch. diff --git a/src/common/Common.UI/SettingsDeepLink.cs b/src/common/Common.UI/SettingsDeepLink.cs index 84945e6939..1891532d16 100644 --- a/src/common/Common.UI/SettingsDeepLink.cs +++ b/src/common/Common.UI/SettingsDeepLink.cs @@ -17,6 +17,7 @@ namespace Common.UI Awake, ColorPicker, CmdNotFound, + LightSwitch, FancyZones, FileLocksmith, Run, @@ -60,6 +61,8 @@ namespace Common.UI return "ColorPicker"; case SettingsWindow.CmdNotFound: return "CmdNotFound"; + case SettingsWindow.LightSwitch: + return "LightSwitch"; case SettingsWindow.FancyZones: return "FancyZones"; case SettingsWindow.FileLocksmith: diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 87ef1721b1..2b256cd926 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -28,6 +28,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredCropAndLockEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredLightSwitchEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredLightSwitchEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredFancyZonesEnabledValue() { return static_cast(powertoys_gpo::getConfiguredFancyZonesEnabledValue()); @@ -108,6 +112,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredMousePointerCrosshairsEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredCursorWrapEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredCursorWrapEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredPowerRenameEnabledValue() { return static_cast(powertoys_gpo::getConfiguredPowerRenameEnabledValue()); @@ -188,6 +196,34 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue()); } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteOpenAIValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteOpenAIValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAzureOpenAIValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteAzureOpenAIValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAzureAIInferenceValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteAzureAIInferenceValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteMistralValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteMistralValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteGoogleValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteGoogleValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteOllamaValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteOllamaValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteFoundryLocalValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusEnabledValue() { return static_cast(powertoys_gpo::getConfiguredNewPlusEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index 33f90e15c9..e57cccccd9 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -13,6 +13,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredCmdPalEnabledValue(); static GpoRuleConfigured GetConfiguredColorPickerEnabledValue(); static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue(); + static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue(); static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue(); static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue(); static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue(); @@ -34,6 +35,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); static GpoRuleConfigured GetConfiguredQuickAccentEnabledValue(); @@ -53,6 +55,13 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredQoiPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredQoiThumbnailsEnabledValue(); static GpoRuleConfigured GetAllowedAdvancedPasteOnlineAIModelsValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureAIInferenceValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteMistralValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue(); static GpoRuleConfigured GetConfiguredNewPlusEnabledValue(); static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue(); static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 252b4d128a..06d035aa35 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -17,6 +17,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredCmdPalEnabledValue(); static GpoRuleConfigured GetConfiguredColorPickerEnabledValue(); static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue(); + static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue(); static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue(); static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue(); static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue(); @@ -37,6 +38,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredMouseWithoutBordersEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); @@ -57,6 +59,13 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredQoiPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredQoiThumbnailsEnabledValue(); static GpoRuleConfigured GetAllowedAdvancedPasteOnlineAIModelsValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureAIInferenceValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteMistralValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue(); static GpoRuleConfigured GetConfiguredNewPlusEnabledValue(); static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue(); static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue(); diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs new file mode 100644 index 0000000000..489a779179 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record FoundryCachedModel(string Name, string? Id); diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs new file mode 100644 index 0000000000..413bb47316 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record FoundryCatalogModel +{ + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; init; } = string.Empty; + + [JsonPropertyName("providerType")] + public string ProviderType { get; init; } = string.Empty; + + [JsonPropertyName("uri")] + public string Uri { get; init; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; init; } = string.Empty; + + [JsonPropertyName("modelType")] + public string ModelType { get; init; } = string.Empty; + + [JsonPropertyName("promptTemplate")] + public PromptTemplate PromptTemplate { get; init; } = default!; + + [JsonPropertyName("publisher")] + public string Publisher { get; init; } = string.Empty; + + [JsonPropertyName("task")] + public string Task { get; init; } = string.Empty; + + [JsonPropertyName("runtime")] + public Runtime Runtime { get; init; } = default!; + + [JsonPropertyName("fileSizeMb")] + public long FileSizeMb { get; init; } + + [JsonPropertyName("modelSettings")] + public ModelSettings ModelSettings { get; init; } = default!; + + [JsonPropertyName("alias")] + public string Alias { get; init; } = string.Empty; + + [JsonPropertyName("supportsToolCalling")] + public bool SupportsToolCalling { get; init; } + + [JsonPropertyName("license")] + public string License { get; init; } = string.Empty; + + [JsonPropertyName("licenseDescription")] + public string LicenseDescription { get; init; } = string.Empty; + + [JsonPropertyName("parentModelUri")] + public string ParentModelUri { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs new file mode 100644 index 0000000000..a279f7389a --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using ManagedCommon; +using Microsoft.AI.Foundry.Local; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed class FoundryClient +{ + public static async Task CreateAsync() + { + // First attempt with current environment + var client = await TryCreateClientAsync().ConfigureAwait(false); + if (client != null) + { + return client; + } + + // If failed, refresh PATH from registry and retry once + // This handles cases where PowerToys was launched by MSI installer. + Logger.LogInfo("[FoundryClient] First attempt failed, refreshing PATH and retrying"); + RefreshEnvironmentPath(); + + return await TryCreateClientAsync().ConfigureAwait(false); + } + + private static async Task TryCreateClientAsync() + { + try + { + Logger.LogInfo("[FoundryClient] Creating Foundry Local client"); + + var manager = new FoundryLocalManager(); + + // Check if service is already running + if (manager.IsServiceRunning) + { + Logger.LogInfo("[FoundryClient] Foundry service is already running"); + return new FoundryClient(manager); + } + + // Start the service using SDK's method + Logger.LogInfo("[FoundryClient] Starting Foundry service using manager.StartServiceAsync()"); + await manager.StartServiceAsync().ConfigureAwait(false); + + Logger.LogInfo("[FoundryClient] Foundry service started successfully"); + return new FoundryClient(manager); + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error creating client: {ex.Message}"); + if (ex.InnerException != null) + { + Logger.LogError($"[FoundryClient] Inner exception: {ex.InnerException.Message}"); + } + + return null; + } + } + + private readonly FoundryLocalManager _foundryManager; + private readonly List _catalogModels = []; + + private FoundryClient(FoundryLocalManager foundryManager) + { + _foundryManager = foundryManager; + } + + public Task GetServiceUrl() + { + try + { + return Task.FromResult(_foundryManager.Endpoint?.ToString()); + } + catch + { + return Task.FromResult(null); + } + } + + public Uri? GetServiceUri() + { + try + { + return _foundryManager.ServiceUri; + } + catch + { + return null; + } + } + + public async Task> ListCatalogModels() + { + if (_catalogModels.Count > 0) + { + return _catalogModels; + } + + try + { + Logger.LogInfo("[FoundryClient] Listing catalog models"); + var models = await _foundryManager.ListCatalogModelsAsync().ConfigureAwait(false); + + if (models != null) + { + foreach (var model in models) + { + _catalogModels.Add(new FoundryCatalogModel + { + Name = model.ModelId ?? string.Empty, + DisplayName = model.DisplayName ?? string.Empty, + ProviderType = model.ProviderType ?? string.Empty, + Uri = model.Uri ?? string.Empty, + Version = model.Version ?? string.Empty, + ModelType = model.ModelType ?? string.Empty, + Publisher = model.Publisher ?? string.Empty, + Task = model.Task ?? string.Empty, + FileSizeMb = model.FileSizeMb, + Alias = model.Alias ?? string.Empty, + License = model.License ?? string.Empty, + LicenseDescription = model.LicenseDescription ?? string.Empty, + ParentModelUri = model.ParentModelUri ?? string.Empty, + SupportsToolCalling = model.SupportsToolCalling, + }); + } + + Logger.LogInfo($"[FoundryClient] Found {_catalogModels.Count} catalog models"); + } + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error listing catalog models: {ex.Message}"); + + // Surfacing errors here prevents listing other providers; swallow and return cached list instead. + } + + return _catalogModels; + } + + public async Task> ListCachedModels() + { + try + { + Logger.LogInfo("[FoundryClient] Listing cached models"); + var cachedModels = await _foundryManager.ListCachedModelsAsync().ConfigureAwait(false); + var catalogModels = await ListCatalogModels().ConfigureAwait(false); + + List models = []; + + foreach (var model in cachedModels) + { + var catalogModel = catalogModels.FirstOrDefault(m => m.Name == model.ModelId); + var alias = catalogModel?.Alias ?? model.Alias; + models.Add(new FoundryCachedModel(model.ModelId ?? string.Empty, alias)); + } + + Logger.LogInfo($"[FoundryClient] Found {models.Count} cached models"); + return models; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error listing cached models: {ex.Message}"); + return []; + } + } + + public async Task IsModelLoaded(string modelId) + { + try + { + var loadedModels = await _foundryManager.ListLoadedModelsAsync().ConfigureAwait(false); + var isLoaded = loadedModels.Any(m => m.ModelId == modelId); + Logger.LogInfo($"[FoundryClient] IsModelLoaded({modelId}): {isLoaded}"); + Logger.LogInfo($"[FoundryClient] Loaded models: {string.Join(", ", loadedModels.Select(m => m.ModelId))}"); + return isLoaded; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] IsModelLoaded exception: {ex.Message}"); + return false; + } + } + + public async Task EnsureModelLoaded(string modelId) + { + Logger.LogInfo($"[FoundryClient] EnsureModelLoaded called with: {modelId}"); + + // Check if already loaded + if (await IsModelLoaded(modelId).ConfigureAwait(false)) + { + Logger.LogInfo($"[FoundryClient] Model already loaded: {modelId}"); + return true; + } + + // Load the model + Logger.LogInfo($"[FoundryClient] Loading model: {modelId}"); + await _foundryManager.LoadModelAsync(modelId).ConfigureAwait(false); + + // Verify it's loaded + var loaded = await IsModelLoaded(modelId).ConfigureAwait(false); + Logger.LogInfo($"[FoundryClient] Model load result: {loaded}"); + return loaded; + } + + public async Task EnsureRunning() + { + if (!_foundryManager.IsServiceRunning) + { + await _foundryManager.StartServiceAsync(); + } + } + + /// + /// Refreshes the PATH environment variable from the system registry. + /// This is necessary when tools are installed while PowerToys is running, + /// as the installer updates the system PATH but running processes don't see the change. + /// + private static void RefreshEnvironmentPath() + { + try + { + Logger.LogInfo("[FoundryClient] Refreshing PATH environment variable from system"); + + var currentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process) ?? string.Empty; + var machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? string.Empty; + var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + + var pathsToAdd = new List(); + + if (!string.IsNullOrWhiteSpace(currentPath)) + { + pathsToAdd.AddRange(currentPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)); + } + + if (!string.IsNullOrWhiteSpace(userPath)) + { + var userPaths = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in userPaths) + { + if (!pathsToAdd.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + pathsToAdd.Add(path); + } + } + } + + if (!string.IsNullOrWhiteSpace(machinePath)) + { + var machinePaths = machinePath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in machinePaths) + { + if (!pathsToAdd.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + pathsToAdd.Add(path); + } + } + } + + var newPath = string.Join(Path.PathSeparator.ToString(), pathsToAdd); + + if (currentPath != newPath) + { + Logger.LogInfo("[FoundryClient] Updating process PATH with latest system values"); + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); + } + else + { + Logger.LogInfo("[FoundryClient] PATH is already up to date"); + } + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Failed to refresh PATH: {ex.Message}"); + } + } +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs new file mode 100644 index 0000000000..5dcb4076ed --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs @@ -0,0 +1,17 @@ +// 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 System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = false)] +[JsonSerializable(typeof(FoundryCatalogModel))] +[JsonSerializable(typeof(List))] +internal sealed partial class FoundryJsonContext : JsonSerializerContext +{ +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs b/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs new file mode 100644 index 0000000000..fda91217eb --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs @@ -0,0 +1,16 @@ +// 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 System.Text.Json; +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record ModelSettings +{ + // The sample shows an empty array; keep it open-ended. + [JsonPropertyName("parameters")] + public List Parameters { get; init; } = []; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs b/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs new file mode 100644 index 0000000000..a2cbb9fe45 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record PromptTemplate +{ + [JsonPropertyName("assistant")] + public string Assistant { get; init; } = string.Empty; + + [JsonPropertyName("prompt")] + public string Prompt { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs b/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs new file mode 100644 index 0000000000..e2019c8f87 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record Runtime +{ + [JsonPropertyName("deviceType")] + public string DeviceType { get; init; } = string.Empty; + + [JsonPropertyName("executionProvider")] + public string ExecutionProvider { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs new file mode 100644 index 0000000000..e7cc30c288 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ClientModel; +using LanguageModelProvider.FoundryLocal; +using ManagedCommon; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace LanguageModelProvider; + +public sealed class FoundryLocalModelProvider : ILanguageModelProvider +{ + private FoundryClient? _foundryClient; + private IEnumerable? _catalogModels; + private string? _serviceUrl; + + public static FoundryLocalModelProvider Instance { get; } = new(); + + public string Name => "FoundryLocal"; + + public string ProviderDescription => "The model will run locally via Foundry Local"; + + public IChatClient? GetIChatClient(string modelId) + { + Logger.LogInfo($"[FoundryLocal] GetIChatClient called with url: {modelId}"); + InitializeAsync().GetAwaiter().GetResult(); + + if (string.IsNullOrWhiteSpace(modelId)) + { + Logger.LogError("[FoundryLocal] Model ID is empty after extraction"); + return null; + } + + // Check if model is in catalog + var isInCatalog = _catalogModels?.Any(m => m.Name == modelId) ?? false; + if (!isInCatalog) + { + var errorMessage = $"{modelId} is not supported in Foundry Local. Please configure supported models in Settings."; + Logger.LogError($"[FoundryLocal] {errorMessage}"); + throw new InvalidOperationException(errorMessage); + } + + // Ensure the model is loaded before returning chat client + var isLoaded = _foundryClient!.EnsureModelLoaded(modelId).GetAwaiter().GetResult(); + if (!isLoaded) + { + Logger.LogError($"[FoundryLocal] Failed to load model: {modelId}"); + throw new InvalidOperationException($"Failed to load the model '{modelId}'."); + } + + // Use ServiceUri instead of Endpoint since Endpoint already includes /v1 + var baseUri = _foundryClient.GetServiceUri(); + if (baseUri == null) + { + const string message = "Foundry Local service URL is not available. Please make sure Foundry Local is installed and running."; + Logger.LogError($"[FoundryLocal] {message}"); + throw new InvalidOperationException(message); + } + + var endpointUri = new Uri($"{baseUri.ToString().TrimEnd('/')}/v1"); + Logger.LogInfo($"[FoundryLocal] Creating OpenAI client with endpoint: {endpointUri}"); + + return new OpenAIClient( + new ApiKeyCredential("none"), + new OpenAIClientOptions { Endpoint = endpointUri }) + .GetChatClient(modelId) + .AsIChatClient(); + } + + public string GetIChatClientString(string url) + { + try + { + InitializeAsync().GetAwaiter().GetResult(); + } + catch + { + return string.Empty; + } + + var modelId = url.Split('/').LastOrDefault(); + + if (string.IsNullOrWhiteSpace(_serviceUrl) || string.IsNullOrWhiteSpace(modelId)) + { + return string.Empty; + } + + return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{_serviceUrl}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()"; + } + + public async Task> GetModelsAsync(CancellationToken cancelationToken = default) + { + await InitializeAsync(cancelationToken); + + if (_foundryClient == null) + { + return Array.Empty(); + } + + var cachedModels = await _foundryClient.ListCachedModels(); + List downloadedModels = []; + + foreach (var model in cachedModels) + { + Logger.LogInfo($"[FoundryLocal] Adding unmatched cached model: {model.Name}"); + downloadedModels.Add(new ModelDetails + { + Id = $"fl-{model.Name}", + Name = model.Name, + Url = $"fl://{model.Name}", + Description = $"{model.Name} running locally with Foundry Local", + HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL], + ProviderModelDetails = model, + }); + } + + return downloadedModels; + } + + private async Task InitializeAsync(CancellationToken cancelationToken = default) + { + if (_foundryClient != null && _catalogModels != null && _catalogModels.Any()) + { + await _foundryClient.EnsureRunning().ConfigureAwait(false); + return; + } + + Logger.LogInfo("[FoundryLocal] Initializing provider"); + _foundryClient ??= await FoundryClient.CreateAsync(); + + if (_foundryClient == null) + { + const string message = "Foundry Local client could not be created. Please make sure Foundry Local is installed and running."; + Logger.LogError($"[FoundryLocal] {message}"); + throw new InvalidOperationException(message); + } + + _serviceUrl ??= await _foundryClient.GetServiceUrl(); + Logger.LogInfo($"[FoundryLocal] Service URL: {_serviceUrl}"); + + var catalogModels = await _foundryClient.ListCatalogModels(); + Logger.LogInfo($"[FoundryLocal] Found {catalogModels.Count} catalog models"); + _catalogModels = catalogModels; + } + + public async Task IsAvailable() + { + Logger.LogInfo("[FoundryLocal] Checking availability"); + await InitializeAsync(); + var available = _foundryClient != null; + Logger.LogInfo($"[FoundryLocal] Available: {available}"); + return available; + } +} diff --git a/src/common/LanguageModelProvider/HardwareAccelerator.cs b/src/common/LanguageModelProvider/HardwareAccelerator.cs new file mode 100644 index 0000000000..d2c94b8155 --- /dev/null +++ b/src/common/LanguageModelProvider/HardwareAccelerator.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace LanguageModelProvider; + +public enum HardwareAccelerator +{ + CPU, + DML, + QNN, + WCRAPI, + OLLAMA, + OPENAI, + FOUNDRYLOCAL, + LEMONADE, + NPU, + GPU, + VitisAI, + OpenVINO, + NvTensorRT, +} diff --git a/src/common/LanguageModelProvider/ILanguageModelProvider.cs b/src/common/LanguageModelProvider/ILanguageModelProvider.cs new file mode 100644 index 0000000000..9d203adaf6 --- /dev/null +++ b/src/common/LanguageModelProvider/ILanguageModelProvider.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.AI; + +namespace LanguageModelProvider; + +public interface ILanguageModelProvider +{ + string Name { get; } + + string ProviderDescription { get; } + + Task> GetModelsAsync(CancellationToken cancelationToken = default); + + IChatClient? GetIChatClient(string modelId); + + string GetIChatClientString(string url); +} diff --git a/src/common/LanguageModelProvider/LanguageModelProvider.csproj b/src/common/LanguageModelProvider/LanguageModelProvider.csproj new file mode 100644 index 0000000000..4dba9247a3 --- /dev/null +++ b/src/common/LanguageModelProvider/LanguageModelProvider.csproj @@ -0,0 +1,20 @@ + + + + + + enable + enable + + + + + + + + + + + + + diff --git a/src/common/LanguageModelProvider/ModelDetails.cs b/src/common/LanguageModelProvider/ModelDetails.cs new file mode 100644 index 0000000000..e383aa7d27 --- /dev/null +++ b/src/common/LanguageModelProvider/ModelDetails.cs @@ -0,0 +1,30 @@ +// 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; + +namespace LanguageModelProvider; + +public class ModelDetails +{ + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public long Size { get; set; } + + public bool IsUserAdded { get; set; } + + public string Icon { get; set; } = string.Empty; + + public List HardwareAccelerators { get; set; } = []; + + public string License { get; set; } = string.Empty; + + public object? ProviderModelDetails { get; set; } +} diff --git a/src/common/ManagedCommon/Logger.cs b/src/common/ManagedCommon/Logger.cs index 150d6ea355..1173920340 100644 --- a/src/common/ManagedCommon/Logger.cs +++ b/src/common/ManagedCommon/Logger.cs @@ -19,11 +19,23 @@ namespace ManagedCommon private static readonly string Error = "Error"; private static readonly string Warning = "Warning"; private static readonly string Info = "Info"; +#if DEBUG private static readonly string Debug = "Debug"; +#endif private static readonly string TraceFlag = "Trace"; private static readonly string Version = Assembly.GetExecutingAssembly().GetCustomAttribute()?.Version ?? "Unknown"; + /// + /// Gets the path to the log directory for the current version of the app. + /// + public static string CurrentVersionLogDirectoryPath { get; private set; } + + /// + /// Gets the path to the log directory for the app. + /// + public static string AppLogDirectoryPath { get; private set; } + /// /// Initializes the logger and sets the path for logging. /// @@ -40,6 +52,9 @@ namespace ManagedCommon Directory.CreateDirectory(versionedPath); } + AppLogDirectoryPath = basePath; + CurrentVersionLogDirectoryPath = versionedPath; + var logFilePath = Path.Combine(versionedPath, "Log_" + DateTime.Now.ToString(@"yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"); Trace.Listeners.Add(new TextWriterTraceListener(logFilePath)); @@ -128,7 +143,7 @@ namespace ManagedCommon { exMessage += "Inner exception: " + Environment.NewLine + - ex.InnerException.GetType() + " (" + ex.HResult + "): " + ex.InnerException.Message + Environment.NewLine; + ex.InnerException.GetType() + " (" + ex.InnerException.HResult + "): " + ex.InnerException.Message + Environment.NewLine; } exMessage += @@ -151,7 +166,9 @@ namespace ManagedCommon public static void LogDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) { +#if DEBUG Log(message, Debug, memberName, sourceFilePath, sourceLineNumber); +#endif } public static void LogTrace([System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index 65b00d4b5a..d7ae386191 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -12,6 +12,7 @@ namespace ManagedCommon ColorPicker, CmdPal, CropAndLock, + CursorWrap, EnvironmentVariables, FancyZones, FileLocksmith, @@ -19,6 +20,7 @@ namespace ManagedCommon Hosts, ImageResizer, KeyboardManager, + LightSwitch, MouseHighlighter, MouseJump, MousePointerCrosshairs, diff --git a/src/common/UITestAutomation/Element/Element.cs b/src/common/UITestAutomation/Element/Element.cs index 1868a9c34d..6e9efabeac 100644 --- a/src/common/UITestAutomation/Element/Element.cs +++ b/src/common/UITestAutomation/Element/Element.cs @@ -81,6 +81,14 @@ namespace Microsoft.PowerToys.UITest get { return this.windowsElement?.Selected ?? false; } } + /// + /// Gets a value indicating whether the UI element is visible to the user. + /// + public bool Displayed + { + get { return this.windowsElement?.Displayed ?? false; } + } + /// /// Gets the Rect of the UI element. /// @@ -329,7 +337,7 @@ namespace Microsoft.PowerToys.UITest /// Send Key of the element. /// /// The Key to Send. - protected void SendKeys(string key) + public void SendKeys(string key) { PerformAction((actions, windowElement) => { @@ -369,5 +377,19 @@ namespace Microsoft.PowerToys.UITest Assert.IsNotNull(this.windowsElement, $"WindowsElement is null in method SaveToPngFile with parameter: path = {path}"); this.windowsElement.GetScreenshot().SaveAsFile(path); } + + public void EnsureVisible(Element scrollViewer, int maxScrolls = 10) + { + int count = 0; + if (scrollViewer.WindowsElement != null) + { + while (!this.windowsElement!.Displayed && count < maxScrolls) + { + scrollViewer.WindowsElement.SendKeys(OpenQA.Selenium.Keys.PageDown); + Task.Delay(250).Wait(); + count++; + } + } + } } } diff --git a/src/common/UITestAutomation/ModuleConfigData.cs b/src/common/UITestAutomation/ModuleConfigData.cs index ac3f5ffe26..4dcd168da3 100644 --- a/src/common/UITestAutomation/ModuleConfigData.cs +++ b/src/common/UITestAutomation/ModuleConfigData.cs @@ -34,6 +34,7 @@ namespace Microsoft.PowerToys.UITest PowerRename, CommandPalette, ScreenRuler, + LightSwitch, } /// @@ -106,6 +107,7 @@ namespace Microsoft.PowerToys.UITest [PowerToysModule.PowerRename] = new ModuleInfo("PowerToys.PowerRename.exe", "PowerRename", "WinUI3Apps"), [PowerToysModule.CommandPalette] = new ModuleInfo("Microsoft.CmdPal.UI.exe", "PowerToys Command Palette", "WinUI3Apps\\CmdPal"), [PowerToysModule.ScreenRuler] = new ModuleInfo("PowerToys.MeasureToolUI.exe", "PowerToys.ScreenRuler", "WinUI3Apps"), + [PowerToysModule.LightSwitch] = new ModuleInfo("PowerToys.LightSwitch.exe", "PowerToys.LightSwitch", "LightSwitchService"), }; } diff --git a/src/common/UITestAutomation/SettingsConfigHelper.cs b/src/common/UITestAutomation/SettingsConfigHelper.cs new file mode 100644 index 0000000000..833ec4f19d --- /dev/null +++ b/src/common/UITestAutomation/SettingsConfigHelper.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; + +namespace Microsoft.PowerToys.UITest +{ + /// + /// Helper class for configuring PowerToys settings for UI tests. + /// + public class SettingsConfigHelper + { + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; + private static readonly SettingsUtils SettingsUtils = new SettingsUtils(); + + /// + /// Configures global PowerToys settings to enable only specified modules and disable all others. + /// + /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. + /// Thrown when modulesToEnable is null. + /// Thrown when settings file operations fail. + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")] + public static void ConfigureGlobalModuleSettings(params string[] modulesToEnable) + { + ArgumentNullException.ThrowIfNull(modulesToEnable); + + try + { + GeneralSettings settings; + try + { + settings = SettingsUtils.GetSettingsOrDefault(); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to load settings, creating defaults: {ex.Message}"); + settings = new GeneralSettings(); + } + + string settingsJson = settings.ToJsonString(); + using (JsonDocument doc = JsonDocument.Parse(settingsJson)) + { + var options = new JsonSerializerOptions { WriteIndented = true }; + var root = doc.RootElement.Clone(); + + if (root.TryGetProperty("enabled", out var enabledElement)) + { + var enabledModules = new Dictionary(); + + foreach (var property in enabledElement.EnumerateObject()) + { + string moduleName = property.Name; + + bool shouldEnable = Array.Exists(modulesToEnable, m => string.Equals(m, moduleName, StringComparison.Ordinal)); + enabledModules[moduleName] = shouldEnable; + } + + var settingsDict = JsonSerializer.Deserialize>(settingsJson); + if (settingsDict != null) + { + settingsDict["enabled"] = enabledModules; + settingsJson = JsonSerializer.Serialize(settingsDict, IndentedJsonOptions); + } + } + } + + SettingsUtils.SaveSettings(settingsJson); + + string enabledList = modulesToEnable.Length > 0 ? string.Join(", ", modulesToEnable) : "none"; + Debug.WriteLine($"Successfully updated global settings"); + Debug.WriteLine($"Enabled modules: {enabledList}"); + } + catch (Exception ex) + { + Debug.WriteLine($"ERROR in ConfigureGlobalModuleSettings: {ex.Message}"); + throw new InvalidOperationException($"Failed to configure global module settings: {ex.Message}", ex); + } + } + + /// + /// Updates a module's settings file. If the file doesn't exist, creates it with default content. + /// If the file exists, reads it and applies the provided update function to modify the settings. + /// + /// The name of the module (e.g., "Peek", "FancyZones"). + /// The default JSON content to use if the settings file doesn't exist. + /// + /// A callback function that modifies the settings dictionary. The function receives the deserialized settings + /// and should modify it in-place. The function should accept a Dictionary<string, object> and not return a value. + /// Example: (settings) => { ((Dictionary<string, object>)settings["properties"])["SomeSetting"] = newValue; } + /// + /// Thrown when moduleName or updateSettingsAction is null. + /// Thrown when settings file operations fail. + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")] + public static void UpdateModuleSettings( + string moduleName, + string defaultSettingsContent, + Action> updateSettingsAction) + { + ArgumentNullException.ThrowIfNull(moduleName); + ArgumentNullException.ThrowIfNull(updateSettingsAction); + + try + { + // Build the path to the module settings file + string powerToysSettingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys"); + + string moduleDirectory = Path.Combine(powerToysSettingsDirectory, moduleName); + string settingsPath = Path.Combine(moduleDirectory, "settings.json"); + + // Ensure directory exists + Directory.CreateDirectory(moduleDirectory); + + // Read existing settings or use default + string existingJson = string.Empty; + if (File.Exists(settingsPath)) + { + existingJson = File.ReadAllText(settingsPath); + } + + Dictionary? settings; + + // If file doesn't exist or is empty, create from defaults + if (string.IsNullOrWhiteSpace(existingJson)) + { + if (string.IsNullOrWhiteSpace(defaultSettingsContent)) + { + throw new ArgumentException("Default settings content must be provided when file doesn't exist.", nameof(defaultSettingsContent)); + } + + settings = JsonSerializer.Deserialize>(defaultSettingsContent) + ?? throw new InvalidOperationException($"Failed to deserialize default settings for {moduleName}"); + + Debug.WriteLine($"Created default settings for {moduleName} at {settingsPath}"); + } + else + { + // Parse existing settings + settings = JsonSerializer.Deserialize>(existingJson) + ?? throw new InvalidOperationException($"Failed to deserialize existing settings for {moduleName}"); + + Debug.WriteLine($"Loaded existing settings for {moduleName} from {settingsPath}"); + } + + // Apply the update action to modify settings + updateSettingsAction(settings); + + // Serialize and save the updated settings using SettingsUtils + string updatedJson = JsonSerializer.Serialize(settings, IndentedJsonOptions); + SettingsUtils.SaveSettings(updatedJson, moduleName); + + Debug.WriteLine($"Successfully updated settings for {moduleName}"); + } + catch (Exception ex) + { + Debug.WriteLine($"ERROR in UpdateModuleSettings for {moduleName}: {ex.Message}"); + throw new InvalidOperationException($"Failed to update settings for {moduleName}: {ex.Message}", ex); + } + } + } +} diff --git a/src/common/UITestAutomation/UITestAutomation.csproj b/src/common/UITestAutomation/UITestAutomation.csproj index 17841e0a60..549b8a430b 100644 --- a/src/common/UITestAutomation/UITestAutomation.csproj +++ b/src/common/UITestAutomation/UITestAutomation.csproj @@ -8,7 +8,7 @@ enable true true - net9.0-windows10.0.22621.0 + net9.0-windows10.0.26100.0 true false @@ -17,10 +17,12 @@ - - + + + + diff --git a/src/common/UITestAutomation/UITestBase.cs b/src/common/UITestAutomation/UITestBase.cs index f44c62ab62..1c72be05f4 100644 --- a/src/common/UITestAutomation/UITestBase.cs +++ b/src/common/UITestAutomation/UITestBase.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; +using System.IO; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -94,6 +95,7 @@ namespace Microsoft.PowerToys.UITest { Task.Delay(1000).Wait(); AddScreenShotsToTestResultsDirectory(); + AddLogFilesToTestResultsDirectory(); } } @@ -598,6 +600,92 @@ namespace Microsoft.PowerToys.UITest } } + /// + /// Copies PowerToys log files to test results directory when test fails. + /// Renames files to include the directory structure after \PowerToys. + /// + protected void AddLogFilesToTestResultsDirectory() + { + try + { + var localAppDataLow = Path.Combine( + Environment.GetEnvironmentVariable("USERPROFILE") ?? string.Empty, + "AppData", + "LocalLow", + "Microsoft", + "PowerToys"); + + if (Directory.Exists(localAppDataLow)) + { + CopyLogFilesFromDirectory(localAppDataLow, string.Empty); + } + + var localAppData = Path.Combine( + Environment.GetEnvironmentVariable("LOCALAPPDATA") ?? string.Empty, + "Microsoft", + "PowerToys"); + + if (Directory.Exists(localAppData)) + { + CopyLogFilesFromDirectory(localAppData, string.Empty); + } + } + catch (Exception ex) + { + // Don't fail the test if log file copying fails + Console.WriteLine($"Failed to copy log files: {ex.Message}"); + } + } + + /// + /// Recursively copies log files from a directory and renames them with directory structure. + /// + /// Source directory to copy from + /// Relative path from PowerToys folder + private void CopyLogFilesFromDirectory(string sourceDir, string relativePath) + { + if (!Directory.Exists(sourceDir)) + { + return; + } + + // Process log files in current directory + var logFiles = Directory.GetFiles(sourceDir, "*.log"); + foreach (var logFile in logFiles) + { + try + { + var fileName = Path.GetFileName(logFile); + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName); + var extension = Path.GetExtension(fileName); + + // Create new filename with directory structure + var directoryPart = string.IsNullOrEmpty(relativePath) ? string.Empty : relativePath.Replace("\\", "-") + "-"; + var newFileName = $"{directoryPart}{fileNameWithoutExt}{extension}"; + + // Copy file to test results directory with new name + var testResultsDir = TestContext.TestResultsDirectory ?? Path.GetTempPath(); + var destinationPath = Path.Combine(testResultsDir, newFileName); + + File.Copy(logFile, destinationPath, true); + TestContext.AddResultFile(destinationPath); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to copy log file {logFile}: {ex.Message}"); + } + } + + // Recursively process subdirectories + var subdirectories = Directory.GetDirectories(sourceDir); + foreach (var subdir in subdirectories) + { + var dirName = Path.GetFileName(subdir); + var newRelativePath = string.IsNullOrEmpty(relativePath) ? dirName : Path.Combine(relativePath, dirName); + CopyLogFilesFromDirectory(subdir, newRelativePath); + } + } + /// /// Restart scope exe. /// diff --git a/src/common/common.instructions.md b/src/common/common.instructions.md new file mode 100644 index 0000000000..f9bde1d388 --- /dev/null +++ b/src/common/common.instructions.md @@ -0,0 +1,16 @@ +--- +applyTo: "**/*.cs,**/*.cpp,**/*.c,**/*.h,**/*.hpp" +--- +# Common – shared libraries guidance (concise) + +Scope +- Logging, IPC, settings, DPI, telemetry, utilities consumed by multiple modules. + +Guidelines +- Avoid breaking public headers/APIs; if changed, search & update all callers. +- Coordinate ABI-impacting struct/class layout changes; keep binary compatibility. +- Watch perf in hot paths (hooks, timers, serialization); avoid avoidable allocations. +- Ask before adding third‑party deps or changing serialization formats. + +Acceptance +- No unintended ABI breaks, no noisy logs, new non-obvious symbols briefly commented. \ No newline at end of file diff --git a/src/common/interop/PowerToys.Interop.vcxproj b/src/common/interop/PowerToys.Interop.vcxproj index aadd8b2ebb..ca29e69cce 100644 --- a/src/common/interop/PowerToys.Interop.vcxproj +++ b/src/common/interop/PowerToys.Interop.vcxproj @@ -63,14 +63,14 @@ - MultiThreadedDebugDLL + MultiThreadedDebug true true - MultiThreadedDLL + MultiThreaded false true false diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index 00cde3b485..881633e05e 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -59,6 +59,7 @@ struct LogSettings inline const static std::string mouseHighlighterLoggerName = "mouse-highlighter"; inline const static std::string mouseJumpLoggerName = "mouse-jump"; inline const static std::string mousePointerCrosshairsLoggerName = "mouse-pointer-crosshairs"; + inline const static std::string cursorWrapLoggerName = "cursor-wrap"; inline const static std::string imageResizerLoggerName = "imageresizer"; inline const static std::string powerRenameLoggerName = "powerrename"; inline const static std::string alwaysOnTopLoggerName = "always-on-top"; @@ -81,6 +82,7 @@ struct LogSettings inline const static std::string workspacesSnapshotToolLoggerName = "workspaces-snapshot-tool"; inline const static std::wstring workspacesSnapshotToolLogPath = L"workspaces-snapshot-tool-log.log"; inline const static std::string zoomItLoggerName = "zoom-it"; + inline const static std::string lightSwitchLoggerName = "light-switch"; inline const static int retention = 30; std::wstring logLevel; LogSettings(); diff --git a/src/common/utils/elevation.h b/src/common/utils/elevation.h index 7f2ecbf6df..e412ce5aa3 100644 --- a/src/common/utils/elevation.h +++ b/src/common/utils/elevation.h @@ -257,7 +257,9 @@ inline HANDLE run_elevated(const std::wstring& file, const std::wstring& params, exec_info.nShow = SW_HIDE; } - return ShellExecuteExW(&exec_info) ? exec_info.hProcess : nullptr; + BOOL result = ShellExecuteExW(&exec_info); + + return result ? exec_info.hProcess : nullptr; } // Run command as non-elevated user, returns true if succeeded, puts the process id into returnPid if returnPid != NULL diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index ed60bc1a37..ab71d09d0b 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace powertoys_gpo { @@ -30,6 +31,7 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_CMD_NOT_FOUND = L"ConfigureEnabledUtilityCmdNotFound"; const std::wstring POLICY_CONFIGURE_ENABLED_COLOR_PICKER = L"ConfigureEnabledUtilityColorPicker"; const std::wstring POLICY_CONFIGURE_ENABLED_CROP_AND_LOCK = L"ConfigureEnabledUtilityCropAndLock"; + const std::wstring POLICY_CONFIGURE_ENABLED_LIGHT_SWITCH = L"ConfigureEnabledUtilityLightSwitch"; const std::wstring POLICY_CONFIGURE_ENABLED_FANCYZONES = L"ConfigureEnabledUtilityFancyZones"; const std::wstring POLICY_CONFIGURE_ENABLED_FILE_LOCKSMITH = L"ConfigureEnabledUtilityFileLocksmith"; const std::wstring POLICY_CONFIGURE_ENABLED_SVG_PREVIEW = L"ConfigureEnabledUtilityFileExplorerSVGPreview"; @@ -50,6 +52,7 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_HIGHLIGHTER = L"ConfigureEnabledUtilityMouseHighlighter"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_JUMP = L"ConfigureEnabledUtilityMouseJump"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS = L"ConfigureEnabledUtilityMousePointerCrosshairs"; + const std::wstring POLICY_CONFIGURE_ENABLED_CURSOR_WRAP = L"ConfigureEnabledUtilityCursorWrap"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_RENAME = L"ConfigureEnabledUtilityPowerRename"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER = L"ConfigureEnabledUtilityPowerLauncher"; const std::wstring POLICY_CONFIGURE_ENABLED_QUICK_ACCENT = L"ConfigureEnabledUtilityQuickAccent"; @@ -81,6 +84,13 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_RUN_AT_STARTUP = L"ConfigureRunAtStartup"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER_ALL_PLUGINS = L"PowerLauncherAllPluginsEnabledState"; const std::wstring POLICY_ALLOW_ADVANCED_PASTE_ONLINE_AI_MODELS = L"AllowPowerToysAdvancedPasteOnlineAIModels"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OPENAI = L"AllowAdvancedPasteOpenAI"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_AZURE_OPENAI = L"AllowAdvancedPasteAzureOpenAI"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_AZURE_AI_INFERENCE = L"AllowAdvancedPasteAzureAIInference"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_MISTRAL = L"AllowAdvancedPasteMistral"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_GOOGLE = L"AllowAdvancedPasteGoogle"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OLLAMA = L"AllowAdvancedPasteOllama"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL = L"AllowAdvancedPasteFoundryLocal"; const std::wstring POLICY_MWB_CLIPBOARD_SHARING_ENABLED = L"MwbClipboardSharingEnabled"; const std::wstring POLICY_MWB_FILE_TRANSFER_ENABLED = L"MwbFileTransferEnabled"; const std::wstring POLICY_MWB_USE_ORIGINAL_USER_INTERFACE = L"MwbUseOriginalUserInterface"; @@ -295,6 +305,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_CROP_AND_LOCK); } + inline gpo_rule_configured_t getConfiguredLightSwitchEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_LIGHT_SWITCH); + } + inline gpo_rule_configured_t getConfiguredFancyZonesEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_FANCYZONES); @@ -395,6 +410,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS); } + inline gpo_rule_configured_t getConfiguredCursorWrapEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_CURSOR_WRAP); + } + inline gpo_rule_configured_t getConfiguredPowerRenameEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_POWER_RENAME); @@ -569,6 +589,41 @@ namespace powertoys_gpo return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_ONLINE_AI_MODELS); } + inline gpo_rule_configured_t getAllowedAdvancedPasteOpenAIValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_OPENAI); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAzureOpenAIValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_AZURE_OPENAI); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAzureAIInferenceValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_AZURE_AI_INFERENCE); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteMistralValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_MISTRAL); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteGoogleValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_GOOGLE); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteOllamaValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_OLLAMA); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteFoundryLocalValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL); + } + inline gpo_rule_configured_t getConfiguredMwbClipboardSharingEnabledValue() { return getConfiguredValue(POLICY_MWB_CLIPBOARD_SHARING_ENABLED); diff --git a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj index 83ef8f96b4..b36e602d25 100644 --- a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj +++ b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj @@ -31,11 +31,6 @@ - + - - - - - diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/BaseDscTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/BaseDscTest.cs new file mode 100644 index 0000000000..2eda4bdac5 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/BaseDscTest.cs @@ -0,0 +1,68 @@ +// 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.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.IO; +using System.Resources; +using PowerToys.DSC.UnitTests.Models; + +namespace PowerToys.DSC.UnitTests; + +public class BaseDscTest +{ + private readonly ResourceManager _resourceManager; + + public BaseDscTest() + { + _resourceManager = new ResourceManager("PowerToys.DSC.Properties.Resources", typeof(PowerToys.DSC.Program).Assembly); + } + + /// + /// Returns the string resource for the given name, formatted with the provided arguments. + /// + /// The name of the resource string. + /// The arguments to format the resource string with. + /// + public string GetResourceString(string name, params string[] args) + { + return string.Format(CultureInfo.InvariantCulture, _resourceManager.GetString(name, CultureInfo.InvariantCulture), args); + } + + /// + /// Execute a dsc command with the provided arguments. + /// + /// + /// + /// + protected DscExecuteResult ExecuteDscCommand(params string[] args) + where T : Command, new() + { + var originalOut = Console.Out; + var originalErr = Console.Error; + + var outSw = new StringWriter(); + var errSw = new StringWriter(); + + try + { + Console.SetOut(outSw); + Console.SetError(errSw); + + var executeResult = new T().Invoke(args); + var output = outSw.ToString(); + var errorOutput = errSw.ToString(); + return new(executeResult == 0, output, errorOutput); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalErr); + outSw.Dispose(); + errSw.Dispose(); + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/CommandTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/CommandTest.cs new file mode 100644 index 0000000000..0941c03fdf --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/CommandTest.cs @@ -0,0 +1,37 @@ +// 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 PowerToys.DSC.Commands; +using PowerToys.DSC.DSCResources; + +namespace PowerToys.DSC.UnitTests; + +[TestClass] +public sealed class CommandTest : BaseDscTest +{ + [TestMethod] + public void GetResource_Found_Success() + { + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName); + + // Assert + Assert.IsTrue(result.Success); + } + + [TestMethod] + public void GetResource_NotFound_Fail() + { + // Arrange + var availableResources = string.Join(", ", BaseCommand.AvailableResources); + + // Act + var result = ExecuteDscCommand("--resource", "ResourceNotFound"); + + // Assert + Assert.IsFalse(result.Success); + Assert.Contains(GetResourceString("InvalidResourceNameError", availableResources), result.Error); + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/Models/DscExecuteResult.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/Models/DscExecuteResult.cs new file mode 100644 index 0000000000..7bf79f1041 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/Models/DscExecuteResult.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using PowerToys.DSC.Models; + +namespace PowerToys.DSC.UnitTests.Models; + +/// +/// Result of executing a DSC command. +/// +public class DscExecuteResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Value indicating whether the command execution was successful. + /// Output stream content. + /// Error stream content. + public DscExecuteResult(bool success, string output, string error) + { + Success = success; + Output = output; + Error = error; + } + + /// + /// Gets a value indicating whether the command execution was successful. + /// + public bool Success { get; } + + /// + /// Gets the output stream content of the operation. + /// + public string Output { get; } + + /// + /// Gets the error stream content of the operation. + /// + public string Error { get; } + + /// + /// Gets the messages from the error stream. + /// + /// List of messages with their levels. + public List<(DscMessageLevel Level, string Message)> Messages() + { + var lines = Error.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + return lines.SelectMany(line => + { + var map = JsonSerializer.Deserialize>(line); + return map.Select(v => (GetMessageLevel(v.Key), v.Value)).ToList(); + }).ToList(); + } + + /// + /// Gets the output as state. + /// + /// State. + public T OutputState() + { + var lines = Output.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + Debug.Assert(lines.Length == 1, "Output should contain exactly one line."); + return JsonSerializer.Deserialize(lines[0]); + } + + /// + /// Gets the output as state and diff. + /// + /// State and diff. + public (T State, List Diff) OutputStateAndDiff() + { + var lines = Output.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + Debug.Assert(lines.Length == 2, "Output should contain exactly two lines."); + var obj = JsonSerializer.Deserialize(lines[0]); + var diff = JsonSerializer.Deserialize>(lines[1]); + return (obj, diff); + } + + /// + /// Gets the message level from a string representation. + /// + /// The string representation of the message level. + /// The level as . + /// Thrown when the level is unknown. + private DscMessageLevel GetMessageLevel(string level) + { + return level switch + { + "error" => DscMessageLevel.Error, + "warn" => DscMessageLevel.Warning, + "info" => DscMessageLevel.Info, + "debug" => DscMessageLevel.Debug, + "trace" => DscMessageLevel.Trace, + _ => throw new ArgumentOutOfRangeException(nameof(level), level, "Unknown message level"), + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/PowerToys.DSC.UnitTests.csproj b/src/dsc/v3/PowerToys.DSC.UnitTests/PowerToys.DSC.UnitTests.csproj new file mode 100644 index 0000000000..d7a8c8c2f8 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/PowerToys.DSC.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + + + false + ..\..\..\..\$(Configuration)\$(Platform)\tests\PowerToys.DSC.Tests\ + + + + + + + + + + diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs new file mode 100644 index 0000000000..45fd36d10e --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs @@ -0,0 +1,35 @@ +// 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 ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceAdvancedPasteModuleTest : SettingsResourceModuleTest +{ + public SettingsResourceAdvancedPasteModuleTest() + : base(nameof(ModuleType.AdvancedPaste)) + { + } + + protected override Action GetSettingsModifier() + { + return s => + { + s.Properties.ShowCustomPreview = !s.Properties.ShowCustomPreview; + s.Properties.CloseAfterLosingFocus = !s.Properties.CloseAfterLosingFocus; + + // s.Properties.IsAdvancedAIEnabled = !s.Properties.IsAdvancedAIEnabled; + s.Properties.AdvancedPasteUIShortcut = new HotkeySettings + { + Key = "mock", + Alt = true, + }; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAlwaysOnTopModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAlwaysOnTopModuleTest.cs new file mode 100644 index 0000000000..5aeb10b27e --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAlwaysOnTopModuleTest.cs @@ -0,0 +1,28 @@ +// 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 ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceAlwaysOnTopModuleTest : SettingsResourceModuleTest +{ + public SettingsResourceAlwaysOnTopModuleTest() + : base(nameof(ModuleType.AlwaysOnTop)) + { + } + + protected override Action GetSettingsModifier() + { + return s => + { + s.Properties.RoundCornersEnabled.Value = !s.Properties.RoundCornersEnabled.Value; + s.Properties.FrameEnabled.Value = !s.Properties.FrameEnabled.Value; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAppModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAppModuleTest.cs new file mode 100644 index 0000000000..b49563e100 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAppModuleTest.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerToys.DSC.DSCResources; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceAppModuleTest : SettingsResourceModuleTest +{ + public SettingsResourceAppModuleTest() + : base(SettingsResource.AppModule) + { + } + + protected override Action GetSettingsModifier() + { + return s => + { + s.Startup = !s.Startup; + s.ShowSysTrayIcon = !s.ShowSysTrayIcon; + s.Enabled.Awake = !s.Enabled.Awake; + s.Enabled.ColorPicker = !s.Enabled.ColorPicker; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAwakeModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAwakeModuleTest.cs new file mode 100644 index 0000000000..bd5e60c371 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAwakeModuleTest.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceAwakeModuleTest : SettingsResourceModuleTest +{ + public SettingsResourceAwakeModuleTest() + : base(nameof(ModuleType.Awake)) + { + } + + protected override Action GetSettingsModifier() + { + return s => + { + s.Properties.ExpirationDateTime = DateTimeOffset.MinValue; + s.Properties.IntervalHours = DefaultSettings.Properties.IntervalHours + 1; + s.Properties.IntervalMinutes = DefaultSettings.Properties.IntervalMinutes + 1; + s.Properties.Mode = s.Properties.Mode == AwakeMode.PASSIVE ? AwakeMode.TIMED : AwakeMode.PASSIVE; + s.Properties.KeepDisplayOn = !s.Properties.KeepDisplayOn; + s.Properties.CustomTrayTimes = new Dictionary + { + { "08:00", 1 }, + { "12:00", 2 }, + { "16:00", 3 }, + }; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceColorPickerModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceColorPickerModuleTest.cs new file mode 100644 index 0000000000..175b74623c --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceColorPickerModuleTest.cs @@ -0,0 +1,28 @@ +// 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 ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceColorPickerModuleTest : SettingsResourceModuleTest +{ + public SettingsResourceColorPickerModuleTest() + : base(nameof(ModuleType.ColorPicker)) + { + } + + protected override Action GetSettingsModifier() + { + return s => + { + s.Properties.ShowColorName = !s.Properties.ShowColorName; + s.Properties.ColorHistoryLimit = s.Properties.ColorHistoryLimit == 0 ? 10 : 0; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCommandTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCommandTest.cs new file mode 100644 index 0000000000..5333f5a832 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCommandTest.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using ManagedCommon; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerToys.DSC.Commands; +using PowerToys.DSC.DSCResources; +using PowerToys.DSC.Models; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceCommandTest : BaseDscTest +{ + [TestMethod] + public void Modules_ListAllSupportedModules() + { + // Arrange + var expectedModules = new List() + { + SettingsResource.AppModule, + nameof(ModuleType.AdvancedPaste), + nameof(ModuleType.AlwaysOnTop), + nameof(ModuleType.Awake), + nameof(ModuleType.ColorPicker), + nameof(ModuleType.CropAndLock), + nameof(ModuleType.EnvironmentVariables), + nameof(ModuleType.FancyZones), + nameof(ModuleType.FileLocksmith), + nameof(ModuleType.FindMyMouse), + nameof(ModuleType.Hosts), + nameof(ModuleType.ImageResizer), + nameof(ModuleType.KeyboardManager), + nameof(ModuleType.MouseHighlighter), + nameof(ModuleType.MouseJump), + nameof(ModuleType.MousePointerCrosshairs), + nameof(ModuleType.Peek), + nameof(ModuleType.PowerRename), + nameof(ModuleType.PowerAccent), + nameof(ModuleType.RegistryPreview), + nameof(ModuleType.MeasureTool), + nameof(ModuleType.ShortcutGuide), + nameof(ModuleType.PowerOCR), + nameof(ModuleType.Workspaces), + nameof(ModuleType.ZoomIt), + }; + + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName); + + // Assert + Assert.IsTrue(result.Success); + Assert.AreEqual(string.Join(Environment.NewLine, expectedModules.Order()), result.Output.Trim()); + } + + [TestMethod] + public void Set_EmptyInput_Fail() + { + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName, "--module", "Awake"); + var messages = result.Messages(); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(1, messages.Count); + Assert.AreEqual(DscMessageLevel.Error, messages[0].Level); + Assert.AreEqual(GetResourceString("InputEmptyOrNullError"), messages[0].Message); + } + + [TestMethod] + public void Test_EmptyInput_Fail() + { + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName, "--module", "Awake"); + var messages = result.Messages(); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(1, messages.Count); + Assert.AreEqual(DscMessageLevel.Error, messages[0].Level); + Assert.AreEqual(GetResourceString("InputEmptyOrNullError"), messages[0].Message); + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCropAndLockModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCropAndLockModuleTest.cs new file mode 100644 index 0000000000..516a5fac86 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCropAndLockModuleTest.cs @@ -0,0 +1,34 @@ +// 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 ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceCropAndLockModuleTest : SettingsResourceModuleTest +{ + public SettingsResourceCropAndLockModuleTest() + : base(nameof(ModuleType.CropAndLock)) + { + } + + protected override Action GetSettingsModifier() + { + return s => + { + s.Properties.ThumbnailHotkey = new KeyboardKeysProperty() + { + Value = new HotkeySettings + { + Key = "mock", + Alt = true, + }, + }; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs new file mode 100644 index 0000000000..ad7eb1d200 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerToys.DSC.Commands; +using PowerToys.DSC.DSCResources; +using PowerToys.DSC.Models.ResourceObjects; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +public abstract class SettingsResourceModuleTest : BaseDscTest + where TSettingsConfig : ISettingsConfig, new() +{ + private readonly SettingsUtils _settingsUtils = new(); + private TSettingsConfig _originalSettings; + + protected TSettingsConfig DefaultSettings => new(); + + protected string Module { get; } + + protected List DiffSettings { get; } = [SettingsResourceObject.SettingsJsonPropertyName]; + + protected List DiffEmpty { get; } = []; + + public SettingsResourceModuleTest(string module) + { + Module = module; + } + + [TestInitialize] + public void TestInitialize() + { + _originalSettings = GetSettings(); + ResetSettingsToDefaultValues(); + } + + [TestCleanup] + public void TestCleanup() + { + SaveSettings(_originalSettings); + } + + [TestMethod] + public void Get_Success() + { + // Arrange + var settingsBeforeExecute = GetSettings(); + + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName, "--module", Module); + var state = result.OutputState>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + } + + [TestMethod] + public void Export_Success() + { + // Arrange + var settingsBeforeExecute = GetSettings(); + + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName, "--module", Module); + var state = result.OutputState>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + } + + [TestMethod] + public void SetWithDiff_Success() + { + // Arrange + var settingsModifier = GetSettingsModifier(); + var input = CreateInputResourceObject(settingsModifier); + + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName, "--module", Module, "--input", input); + var (state, diff) = result.OutputStateAndDiff>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsHasChanged(settingsModifier); + AssertStateAndSettingsAreEqual(GetSettings(), state); + CollectionAssert.AreEqual(DiffSettings, diff); + } + + [TestMethod] + public void SetWithoutDiff_Success() + { + // Arrange + var settingsModifier = GetSettingsModifier(); + UpdateSettings(settingsModifier); + var settingsBeforeExecute = GetSettings(); + var input = CreateInputResourceObject(settingsModifier); + + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName, "--module", Module, "--input", input); + var (state, diff) = result.OutputStateAndDiff>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + CollectionAssert.AreEqual(DiffEmpty, diff); + } + + [TestMethod] + public void TestWithDiff_Success() + { + // Arrange + var settingsModifier = GetSettingsModifier(); + var settingsBeforeExecute = GetSettings(); + var input = CreateInputResourceObject(settingsModifier); + + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName, "--module", Module, "--input", input); + var (state, diff) = result.OutputStateAndDiff>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + CollectionAssert.AreEqual(DiffSettings, diff); + Assert.IsFalse(state.InDesiredState); + } + + [TestMethod] + public void TestWithoutDiff_Success() + { + // Arrange + var settingsModifier = GetSettingsModifier(); + UpdateSettings(settingsModifier); + var settingsBeforeExecute = GetSettings(); + var input = CreateInputResourceObject(settingsModifier); + + // Act + var result = ExecuteDscCommand("--resource", SettingsResource.ResourceName, "--module", Module, "--input", input); + var (state, diff) = result.OutputStateAndDiff>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + CollectionAssert.AreEqual(DiffEmpty, diff); + Assert.IsTrue(state.InDesiredState); + } + + /// + /// Gets the settings modifier action for the specific settings configuration. + /// + /// An action that modifies the settings configuration. + protected abstract Action GetSettingsModifier(); + + /// + /// Resets the settings to default values. + /// + private void ResetSettingsToDefaultValues() + { + SaveSettings(DefaultSettings); + } + + /// + /// Get the settings for the specified module. + /// + /// An instance of the settings type with the current configuration. + private TSettingsConfig GetSettings() + { + return _settingsUtils.GetSettingsOrDefault(DefaultSettings.GetModuleName()); + } + + /// + /// Saves the settings for the specified module. + /// + /// Settings to save. + private void SaveSettings(TSettingsConfig settings) + { + _settingsUtils.SaveSettings(JsonSerializer.Serialize(settings), DefaultSettings.GetModuleName()); + } + + /// + /// Create the resource object for the operation. + /// + /// Settings to include in the resource object. + /// A JSON string representing the resource object. + private string CreateResourceObject(TSettingsConfig settings) + { + var resourceObject = new SettingsResourceObject + { + Settings = settings, + }; + return JsonSerializer.Serialize(resourceObject); + } + + private string CreateInputResourceObject(Action settingsModifier) + { + var settings = DefaultSettings; + settingsModifier(settings); + return CreateResourceObject(settings); + } + + /// + /// Create the response for the Get operation. + /// + /// A JSON string representing the response. + private string CreateGetResponse() + { + return CreateResourceObject(GetSettings()); + } + + /// + /// Asserts that the state and settings are equal. + /// + /// Settings manifest to compare against. + /// Output state to compare. + private void AssertStateAndSettingsAreEqual(TSettingsConfig settings, SettingsResourceObject state) + { + AssertSettingsAreEqual(settings, state.Settings); + } + + /// + /// Asserts that two settings manifests are equal. + /// + /// Expected settings. + /// Actual settings. + private void AssertSettingsAreEqual(TSettingsConfig expected, TSettingsConfig actual) + { + var expectedJson = JsonSerializer.SerializeToNode(expected) as JsonObject; + var actualJson = JsonSerializer.SerializeToNode(actual) as JsonObject; + Assert.IsTrue(JsonNode.DeepEquals(expectedJson, actualJson)); + } + + /// + /// Asserts that the current settings have changed. + /// + /// Action to prepare the default settings. + private void AssertSettingsHasChanged(Action action) + { + var currentSettings = GetSettings(); + var defaultSettings = DefaultSettings; + action(defaultSettings); + AssertSettingsAreEqual(defaultSettings, currentSettings); + } + + /// + /// Updates the settings. + /// + /// Action to modify the settings. + private void UpdateSettings(Action action) + { + var settings = GetSettings(); + action(settings); + SaveSettings(settings); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/BaseCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/BaseCommand.cs new file mode 100644 index 0000000000..d8cfaaefc6 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/BaseCommand.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.IO; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using PowerToys.DSC.DSCResources; +using PowerToys.DSC.Options; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// +/// Base class for all DSC commands. +/// +public abstract class BaseCommand : Command +{ + private static readonly CompositeFormat ModuleNotSupportedByResource = CompositeFormat.Parse(Resources.ModuleNotSupportedByResource); + + // Shared options for all commands + private readonly ModuleOption _moduleOption; + private readonly ResourceOption _resourceOption; + private readonly InputOption _inputOption; + + // The dictionary of available resources and their factories. + private static readonly Dictionary> _resourceFactories = new() + { + { SettingsResource.ResourceName, module => new SettingsResource(module) }, + + // Add other resources here + }; + + /// + /// Gets the list of available DSC resources that can be used with the command. + /// + public static List AvailableResources => [.._resourceFactories.Keys]; + + /// + /// Gets the DSC resource to be used by the command. + /// + protected BaseResource? Resource { get; private set; } + + /// + /// Gets the input JSON provided by the user. + /// + protected string? Input { get; private set; } + + /// + /// Gets the PowerToys module to be used by the command. + /// + protected string? Module { get; private set; } + + public BaseCommand(string name, string description) + : base(name, description) + { + // Register the common options for all commands + _moduleOption = new ModuleOption(); + AddOption(_moduleOption); + + _resourceOption = new ResourceOption(AvailableResources); + AddOption(_resourceOption); + + _inputOption = new InputOption(); + AddOption(_inputOption); + + // Register the command handler + this.SetHandler(CommandHandler); + } + + /// + /// Handles the command invocation. + /// + /// The invocation context containing the parsed command options. + public void CommandHandler(InvocationContext context) + { + Input = context.ParseResult.GetValueForOption(_inputOption); + Module = context.ParseResult.GetValueForOption(_moduleOption); + Resource = ResolvedResource(context); + + // Validate the module against the resource's supported modules + var supportedModules = Resource.GetSupportedModules(); + if (!string.IsNullOrEmpty(Module) && !supportedModules.Contains(Module)) + { + var errorMessage = string.Format(CultureInfo.InvariantCulture, ModuleNotSupportedByResource, Module, Resource.Name); + context.Console.Error.WriteLine(errorMessage); + context.ExitCode = 1; + return; + } + + // Continue with the command handler logic + CommandHandlerInternal(context); + } + + /// + /// Handles the command logic internally. + /// + /// Invocation context containing the parsed command options. + public abstract void CommandHandlerInternal(InvocationContext context); + + /// + /// Resolves the resource from the provided resource name in the context. + /// + /// Invocation context containing the parsed command options. + /// The resolved instance. + private BaseResource ResolvedResource(InvocationContext context) + { + // Resource option has already been validated before the command + // handler is invoked. + var resourceName = context.ParseResult.GetValueForOption(_resourceOption); + Debug.Assert(!string.IsNullOrEmpty(resourceName), "Resource name must not be null or empty."); + Debug.Assert(_resourceFactories.ContainsKey(resourceName), $"Resource '{resourceName}' is not registered."); + return _resourceFactories[resourceName](Module); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/ExportCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/ExportCommand.cs new file mode 100644 index 0000000000..e8001fd0bd --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/ExportCommand.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// +/// Command to export all state instances. +/// +public sealed class ExportCommand : BaseCommand +{ + public ExportCommand() + : base("export", Resources.ExportCommandDescription) + { + } + + /// + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.ExportState(Input) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/GetCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/GetCommand.cs new file mode 100644 index 0000000000..a5fed7bc73 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/GetCommand.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// +/// Command to get the resource state. +/// +public sealed class GetCommand : BaseCommand +{ + public GetCommand() + : base("get", Resources.GetCommandDescription) + { + } + + /// + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.GetState(Input) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/ManifestCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/ManifestCommand.cs new file mode 100644 index 0000000000..da3c637137 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/ManifestCommand.cs @@ -0,0 +1,34 @@ +// 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.CommandLine.Invocation; +using PowerToys.DSC.Options; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// +/// Command to get the manifest of the DSC resource. +/// +public sealed class ManifestCommand : BaseCommand +{ + /// + /// Option to specify the output directory for the manifest. + /// + private readonly OutputDirectoryOption _outputDirectoryOption; + + public ManifestCommand() + : base("manifest", Resources.ManifestCommandDescription) + { + _outputDirectoryOption = new OutputDirectoryOption(); + AddOption(_outputDirectoryOption); + } + + /// + public override void CommandHandlerInternal(InvocationContext context) + { + var outputDir = context.ParseResult.GetValueForOption(_outputDirectoryOption); + context.ExitCode = Resource!.Manifest(outputDir) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/ModulesCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/ModulesCommand.cs new file mode 100644 index 0000000000..9eb60659df --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/ModulesCommand.cs @@ -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; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Diagnostics; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// +/// Command to get all supported modules for a specific resource. +/// +/// +/// This class is primarily used for debugging purposes and for build scripts. +/// +public sealed class ModulesCommand : BaseCommand +{ + public ModulesCommand() + : base("modules", Resources.ModulesCommandDescription) + { + } + + /// + public override void CommandHandlerInternal(InvocationContext context) + { + // Module is optional, if not provided, all supported modules for the + // resource will be printed. If provided, it must be one of the + // supported modules since it has been validated before this command is + // executed. + if (!string.IsNullOrEmpty(Module)) + { + Debug.Assert(Resource!.GetSupportedModules().Contains(Module), "Module must be present in the list of supported modules."); + context.Console.WriteLine(Module); + } + else + { + // Print the supported modules for the specified resource + foreach (var module in Resource!.GetSupportedModules()) + { + context.Console.WriteLine(module); + } + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/SchemaCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/SchemaCommand.cs new file mode 100644 index 0000000000..f7fbfc2448 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/SchemaCommand.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// +/// Command to output the schema of the resource. +/// +public sealed class SchemaCommand : BaseCommand +{ + public SchemaCommand() + : base("schema", Resources.SchemaCommandDescription) + { + } + + /// + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.Schema() ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/SetCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/SetCommand.cs new file mode 100644 index 0000000000..f76c24a0a8 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/SetCommand.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// +/// Command to set the resource state. +/// +public sealed class SetCommand : BaseCommand +{ + public SetCommand() + : base("set", Resources.SetCommandDescription) + { + } + + /// + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.SetState(Input) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/TestCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/TestCommand.cs new file mode 100644 index 0000000000..fcdd83342e --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/TestCommand.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// +/// Command to test the resource state. +/// +public sealed class TestCommand : BaseCommand +{ + public TestCommand() + : base("test", Resources.TestCommandDescription) + { + } + + /// + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.TestState(Input) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/DSCResources/BaseResource.cs b/src/dsc/v3/PowerToys.DSC/DSCResources/BaseResource.cs new file mode 100644 index 0000000000..51d265cff7 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/DSCResources/BaseResource.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using PowerToys.DSC.Models; + +namespace PowerToys.DSC.DSCResources; + +/// +/// Base class for all DSC resources. +/// +public abstract class BaseResource +{ + /// + /// Gets the name of the resource. + /// + public string Name { get; } + + /// + /// Gets the module being used by the resource, if provided. + /// + public string? Module { get; } + + public BaseResource(string name, string? module) + { + Name = name; + Module = module; + } + + /// + /// Calls the get method on the resource. + /// + /// The input string, if any. + /// True if the operation was successful; otherwise false. + public abstract bool GetState(string? input); + + /// + /// Calls the set method on the resource. + /// + /// The input string, if any. + /// True if the operation was successful; otherwise false. + public abstract bool SetState(string? input); + + /// + /// Calls the test method on the resource. + /// + /// The input string, if any. + /// True if the operation was successful; otherwise false. + public abstract bool TestState(string? input); + + /// + /// Calls the export method on the resource. + /// + /// The input string, if any. + /// True if the operation was successful; otherwise false. + public abstract bool ExportState(string? input); + + /// + /// Calls the schema method on the resource. + /// + /// True if the operation was successful; otherwise false. + public abstract bool Schema(); + + /// + /// Generates a DSC resource JSON manifest for the resource. If the + /// outputDir is not provided, the manifest will be printed to the console. + /// + /// The directory where the manifest should be + /// saved. If null, the manifest will be printed to the console. + /// True if the manifest was successfully generated and saved,otherwise false. + public abstract bool Manifest(string? outputDir); + + /// + /// Gets the list of supported modules for the resource. + /// + /// Gets a list of supported modules. + public abstract IList GetSupportedModules(); + + /// + /// Writes a JSON output line to the console. + /// + /// The JSON output to write. + protected void WriteJsonOutputLine(JsonNode output) + { + var json = output.ToJsonString(new() { WriteIndented = false }); + WriteJsonOutputLine(json); + } + + /// + /// Writes a JSON output line to the console. + /// + /// The JSON output to write. + protected void WriteJsonOutputLine(string output) + { + Console.WriteLine(output); + } + + /// + /// Writes a message output line to the console with the specified message level. + /// + /// The level of the message. + /// The message to write. + protected void WriteMessageOutputLine(DscMessageLevel level, string message) + { + var messageObj = new Dictionary + { + [GetMessageLevel(level)] = message, + }; + var messageJson = System.Text.Json.JsonSerializer.Serialize(messageObj); + Console.Error.WriteLine(messageJson); + } + + /// + /// Gets the message level as a string based on the provided dsc message level enum value. + /// + /// The dsc message level. + /// A string representation of the message level. + /// Thrown when the provided message level is not recognized. + private static string GetMessageLevel(DscMessageLevel level) + { + return level switch + { + DscMessageLevel.Error => "error", + DscMessageLevel.Warning => "warn", + DscMessageLevel.Info => "info", + DscMessageLevel.Debug => "debug", + DscMessageLevel.Trace => "trace", + _ => throw new ArgumentOutOfRangeException(nameof(level), level, null), + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/DSCResources/SettingsResource.cs b/src/dsc/v3/PowerToys.DSC/DSCResources/SettingsResource.cs new file mode 100644 index 0000000000..5f69b20227 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/DSCResources/SettingsResource.cs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using PowerToys.DSC.Models; +using PowerToys.DSC.Models.FunctionData; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.DSCResources; + +/// +/// Represents the DSC resource for managing PowerToys settings. +/// +public sealed class SettingsResource : BaseResource +{ + private static readonly CompositeFormat FailedToWriteManifests = CompositeFormat.Parse(Resources.FailedToWriteManifests); + + public const string AppModule = "App"; + public const string ResourceName = "settings"; + + private readonly Dictionary> _moduleFunctionData; + + public string ModuleOrDefault => string.IsNullOrEmpty(Module) ? AppModule : Module; + + public SettingsResource(string? module) + : base(ResourceName, module) + { + _moduleFunctionData = new() + { + { AppModule, CreateModuleFunctionData }, + { nameof(ModuleType.AdvancedPaste), CreateModuleFunctionData }, + { nameof(ModuleType.AlwaysOnTop), CreateModuleFunctionData }, + { nameof(ModuleType.Awake), CreateModuleFunctionData }, + { nameof(ModuleType.ColorPicker), CreateModuleFunctionData }, + { nameof(ModuleType.CropAndLock), CreateModuleFunctionData }, + { nameof(ModuleType.EnvironmentVariables), CreateModuleFunctionData }, + { nameof(ModuleType.FancyZones), CreateModuleFunctionData }, + { nameof(ModuleType.FileLocksmith), CreateModuleFunctionData }, + { nameof(ModuleType.FindMyMouse), CreateModuleFunctionData }, + { nameof(ModuleType.Hosts), CreateModuleFunctionData }, + { nameof(ModuleType.ImageResizer), CreateModuleFunctionData }, + { nameof(ModuleType.KeyboardManager), CreateModuleFunctionData }, + { nameof(ModuleType.MouseHighlighter), CreateModuleFunctionData }, + { nameof(ModuleType.MouseJump), CreateModuleFunctionData }, + { nameof(ModuleType.MousePointerCrosshairs), CreateModuleFunctionData }, + { nameof(ModuleType.Peek), CreateModuleFunctionData }, + { nameof(ModuleType.PowerRename), CreateModuleFunctionData }, + { nameof(ModuleType.PowerAccent), CreateModuleFunctionData }, + { nameof(ModuleType.RegistryPreview), CreateModuleFunctionData }, + { nameof(ModuleType.MeasureTool), CreateModuleFunctionData }, + { nameof(ModuleType.ShortcutGuide), CreateModuleFunctionData }, + { nameof(ModuleType.PowerOCR), CreateModuleFunctionData }, + { nameof(ModuleType.Workspaces), CreateModuleFunctionData }, + { nameof(ModuleType.ZoomIt), CreateModuleFunctionData }, + + // The following modules are not currently supported: + // - MouseWithoutBorders Contains sensitive configuration values, making export/import potentially insecure. + // - PowerLauncher Uses absolute file paths in its settings, which are not portable across systems. + // - NewPlus Uses absolute file paths in its settings, which are not portable across systems. + }; + } + + /// + public override bool ExportState(string? input) + { + var data = CreateFunctionData(); + data.GetState(); + WriteJsonOutputLine(data.Output.ToJson()); + return true; + } + + /// + public override bool GetState(string? input) + { + return ExportState(input); + } + + /// + public override bool SetState(string? input) + { + if (string.IsNullOrEmpty(input)) + { + WriteMessageOutputLine(DscMessageLevel.Error, Resources.InputEmptyOrNullError); + return false; + } + + var data = CreateFunctionData(input); + data.GetState(); + + // Capture the diff before updating the output + var diff = data.GetDiffJson(); + + // Only call Set if the desired state is different from the current state + if (!data.TestState()) + { + var inputSettings = data.Input.SettingsInternal; + data.Output.SettingsInternal = inputSettings; + data.SetState(); + } + + WriteJsonOutputLine(data.Output.ToJson()); + WriteJsonOutputLine(diff); + return true; + } + + /// + public override bool TestState(string? input) + { + if (string.IsNullOrEmpty(input)) + { + WriteMessageOutputLine(DscMessageLevel.Error, Resources.InputEmptyOrNullError); + return false; + } + + var data = CreateFunctionData(input); + data.GetState(); + data.Output.InDesiredState = data.TestState(); + + WriteJsonOutputLine(data.Output.ToJson()); + WriteJsonOutputLine(data.GetDiffJson()); + return true; + } + + /// + public override bool Schema() + { + var data = CreateFunctionData(); + WriteJsonOutputLine(data.Schema()); + return true; + } + + /// + /// + /// If an output directory is specified, write the manifests to files, + /// otherwise output them to the console. + /// + public override bool Manifest(string? outputDir) + { + var manifests = GenerateManifests(); + + if (!string.IsNullOrEmpty(outputDir)) + { + try + { + foreach (var (name, manifest) in manifests) + { + File.WriteAllText(Path.Combine(outputDir, $"microsoft.powertoys.{name}.settings.dsc.resource.json"), manifest); + } + } + catch (Exception ex) + { + var errorMessage = string.Format(CultureInfo.InvariantCulture, FailedToWriteManifests, outputDir, ex.Message); + WriteMessageOutputLine(DscMessageLevel.Error, errorMessage); + return false; + } + } + else + { + foreach (var (_, manifest) in manifests) + { + WriteJsonOutputLine(manifest); + } + } + + return true; + } + + /// + /// Generates manifests for the specified module or all supported modules + /// if no module is specified. + /// + /// A list of tuples containing the module name and its corresponding manifest JSON. + private List<(string Name, string Manifest)> GenerateManifests() + { + List<(string Name, string Manifest)> manifests = []; + if (!string.IsNullOrEmpty(Module)) + { + manifests.Add((Module, GenerateManifest(Module))); + } + else + { + foreach (var module in GetSupportedModules()) + { + manifests.Add((module, GenerateManifest(module))); + } + } + + return manifests; + } + + /// + /// Generate a DSC resource JSON manifest for the specified module. + /// + /// The name of the module for which to generate the manifest. + /// A JSON string representing the DSC resource manifest. + private string GenerateManifest(string module) + { + // Note: The description is not localized because the generated + // manifest file will be part of the package + return new DscManifest($"{module}Settings", "0.1.0") + .AddDescription($"Allows management of {module} settings state via the DSC v3 command line interface protocol.") + .AddStdinMethod("export", ["export", "--module", module, "--resource", "settings"]) + .AddStdinMethod("get", ["get", "--module", module, "--resource", "settings"]) + .AddJsonInputMethod("set", "--input", ["set", "--module", module, "--resource", "settings"], implementsPretest: true, stateAndDiff: true) + .AddJsonInputMethod("test", "--input", ["test", "--module", module, "--resource", "settings"], stateAndDiff: true) + .AddCommandMethod("schema", ["schema", "--module", module, "--resource", "settings"]) + .ToJson(); + } + + /// + public override IList GetSupportedModules() + { + return [.. _moduleFunctionData.Keys.Order()]; + } + + /// + /// Creates the function data for the specified module or the default module if none is specified. + /// + /// The input string, if any. + /// An instance of for the specified module. + public ISettingsFunctionData CreateFunctionData(string? input = null) + { + Debug.Assert(_moduleFunctionData.ContainsKey(ModuleOrDefault), "Module should be supported by the resource."); + return _moduleFunctionData[ModuleOrDefault](input); + } + + /// + /// Creates the function data for a specific settings configuration type. + /// + /// The type of settings configuration to create function data for. + /// The input string, if any. + /// An instance of for the specified settings configuration type. + private ISettingsFunctionData CreateModuleFunctionData(string? input) + where TSettingsConfig : ISettingsConfig, new() + { + return new SettingsFunctionData(input); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs b/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs new file mode 100644 index 0000000000..5eb91acec3 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace PowerToys.DSC.Models; + +/// +/// Class for building a DSC manifest for PowerToys resources. +/// +public sealed class DscManifest +{ + private const string Schema = "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.vscode.json"; + private const string Executable = @"..\PowerToys.DSC.exe"; + + private readonly string _type; + private readonly string _version; + private readonly JsonObject _manifest; + + public DscManifest(string type, string version) + { + _type = type; + _version = version; + _manifest = new JsonObject + { + ["$schema"] = Schema, + ["type"] = $"Microsoft.PowerToys/{_type}", + ["version"] = _version, + ["tags"] = new JsonArray("PowerToys"), + }; + } + + /// + /// Adds a description to the manifest. + /// + /// The description to add. + /// Returns the current instance of . + public DscManifest AddDescription(string description) + { + _manifest["description"] = description; + return this; + } + + /// + /// Adds a method to the manifest with the specified executable and arguments. + /// + /// The name of the method to add. + /// The input argument for the method + /// The list of arguments for the method. + /// Whether the method implements a pretest. + /// Whether the method returns state and diff. + /// Returns the current instance of . + public DscManifest AddJsonInputMethod(string method, string inputArg, List args, bool? implementsPretest = null, bool? stateAndDiff = null) + { + var argsJson = CreateJsonArray(args); + argsJson.Add(new JsonObject + { + ["jsonInputArg"] = inputArg, + ["mandatory"] = true, + }); + var methodObject = AddMethod(argsJson, implementsPretest, stateAndDiff); + _manifest[method] = methodObject; + return this; + } + + /// + /// Adds a method to the manifest that reads from standard input (stdin). + /// + /// The name of the method to add. + /// The list of arguments for the method. + /// Whether the method implements a pretest. + /// Whether the method returns state and diff. + /// Returns the current instance of . + public DscManifest AddStdinMethod(string method, List args, bool? implementsPretest = null, bool? stateAndDiff = null) + { + var argsJson = CreateJsonArray(args); + var methodObject = AddMethod(argsJson, implementsPretest, stateAndDiff); + methodObject["input"] = "stdin"; + _manifest[method] = methodObject; + return this; + } + + /// + /// Adds a command method to the manifest. + /// + /// The name of the method to add. + /// The list of arguments for the method. + /// Returns the current instance of . + public DscManifest AddCommandMethod(string method, List args) + { + _manifest[method] = new JsonObject + { + ["command"] = AddMethod(CreateJsonArray(args)), + }; + return this; + } + + /// + /// Gets the JSON representation of the manifest. + /// + /// Returns the JSON string of the manifest. + public string ToJson() + { + return _manifest.ToJsonString(new() { WriteIndented = true }); + } + + /// + /// Add a method to the manifest with the specified arguments. + /// + /// The list of arguments for the method. + /// Whether the method implements a pretest. + /// Whether the method returns state and diff. + /// Returns the method object. + private JsonObject AddMethod(JsonArray args, bool? implementsPretest = null, bool? stateAndDiff = null) + { + var methodObject = new JsonObject + { + ["executable"] = Executable, + ["args"] = args, + }; + + if (implementsPretest.HasValue) + { + methodObject["implementsPretest"] = implementsPretest.Value; + } + + if (stateAndDiff.HasValue) + { + methodObject["return"] = stateAndDiff.Value ? "stateAndDiff" : "state"; + } + + return methodObject; + } + + /// + /// Creates a JSON array from a list of strings. + /// + /// The list of strings to convert. + /// Returns the JSON array. + private JsonArray CreateJsonArray(List args) + { + var jsonArray = new JsonArray(); + foreach (var arg in args) + { + jsonArray.Add(arg); + } + + return jsonArray; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/DscMessageLevel.cs b/src/dsc/v3/PowerToys.DSC/Models/DscMessageLevel.cs new file mode 100644 index 0000000000..9c5b12b3c0 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/DscMessageLevel.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerToys.DSC.Models; + +/// +/// Specifies the severity level of a message. +/// +public enum DscMessageLevel +{ + /// + /// Represents an error message. + /// + Error, + + /// + /// Represents a warning message. + /// + Warning, + + /// + /// Represents an informational message. + /// + Info, + + /// + /// Represents a debug message. + /// + Debug, + + /// + /// Represents a trace message. + /// + Trace, +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/FunctionData/BaseFunctionData.cs b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/BaseFunctionData.cs new file mode 100644 index 0000000000..4456beed82 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/BaseFunctionData.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Newtonsoft.Json; +using NJsonSchema.Generation; +using PowerToys.DSC.Models.ResourceObjects; + +namespace PowerToys.DSC.Models.FunctionData; + +/// +/// Base class for function data objects. +/// +public class BaseFunctionData +{ + /// + /// Generates a JSON schema for the specified resource object type. + /// + /// The type of the resource object. + /// A JSON schema string. + protected static string GenerateSchema() + where T : BaseResourceObject + { + var settings = new SystemTextJsonSchemaGeneratorSettings() + { + FlattenInheritanceHierarchy = true, + SerializerOptions = + { + IgnoreReadOnlyFields = true, + }, + }; + var generator = new JsonSchemaGenerator(settings); + var schema = generator.Generate(typeof(T)); + return schema.ToJson(Formatting.None); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/FunctionData/ISettingsFunctionData.cs b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/ISettingsFunctionData.cs new file mode 100644 index 0000000000..7cf02d1c74 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/ISettingsFunctionData.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Nodes; +using PowerToys.DSC.Models.ResourceObjects; + +namespace PowerToys.DSC.Models.FunctionData; + +/// +/// Interface for function data related to settings. +/// +public interface ISettingsFunctionData +{ + /// + /// Gets the input settings resource object. + /// + public ISettingsResourceObject Input { get; } + + /// + /// Gets the output settings resource object. + /// + public ISettingsResourceObject Output { get; } + + /// + /// Gets the current settings. + /// + public void GetState(); + + /// + /// Sets the current settings. + /// + public void SetState(); + + /// + /// Tests if the current settings and the desired state are valid. + /// + /// True if the current settings match the desired state; otherwise false. + public bool TestState(); + + /// + /// Gets the difference between the current settings and the desired state in JSON format. + /// + /// A JSON array representing the differences. + public JsonArray GetDiffJson(); + + /// + /// Gets the schema for the settings resource object. + /// + /// + public string Schema(); +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs new file mode 100644 index 0000000000..7fcce03d33 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using PowerToys.DSC.Models.ResourceObjects; + +namespace PowerToys.DSC.Models.FunctionData; + +/// +/// Represents function data for the settings DSC resource. +/// +/// The module settings configuration type. +public sealed class SettingsFunctionData : BaseFunctionData, ISettingsFunctionData + where TSettingsConfig : ISettingsConfig, new() +{ + private static readonly SettingsUtils _settingsUtils = new(); + private static readonly TSettingsConfig _settingsConfig = new(); + + private readonly SettingsResourceObject _input; + private readonly SettingsResourceObject _output; + + /// + public ISettingsResourceObject Input => _input; + + /// + public ISettingsResourceObject Output => _output; + + public SettingsFunctionData(string? input = null) + { + _output = new(); + _input = string.IsNullOrEmpty(input) ? new() : JsonSerializer.Deserialize>(input) ?? new(); + } + + /// + public void GetState() + { + _output.Settings = GetSettings(); + } + + /// + public void SetState() + { + Debug.Assert(_output.Settings != null, "Output settings should not be null"); + SaveSettings(_output.Settings); + } + + /// + public bool TestState() + { + var input = JsonSerializer.SerializeToNode(_input.Settings); + var output = JsonSerializer.SerializeToNode(_output.Settings); + return JsonNode.DeepEquals(input, output); + } + + /// + public JsonArray GetDiffJson() + { + var diff = new JsonArray(); + if (!TestState()) + { + diff.Add(SettingsResourceObject.SettingsJsonPropertyName); + } + + return diff; + } + + /// + public string Schema() + { + return GenerateSchema>(); + } + + /// + /// Gets the settings configuration from the settings utils for a specific module. + /// + /// The settings configuration for the module. + private static TSettingsConfig GetSettings() + { + return _settingsUtils.GetSettingsOrDefault(_settingsConfig.GetModuleName()); + } + + /// + /// Saves the settings configuration to the settings utils for a specific module. + /// + /// Settings of a specific module + private static void SaveSettings(TSettingsConfig settings) + { + var inputJson = JsonSerializer.Serialize(settings); + _settingsUtils.SaveSettings(inputJson, _settingsConfig.GetModuleName()); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/BaseResourceObject.cs b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/BaseResourceObject.cs new file mode 100644 index 0000000000..d6e3e08dcc --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/BaseResourceObject.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace PowerToys.DSC.Models.ResourceObjects; + +/// +/// Base class for all resource objects. +/// +public class BaseResourceObject +{ + private readonly JsonSerializerOptions _options; + + public BaseResourceObject() + { + _options = new() + { + WriteIndented = false, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + } + + /// + /// Gets or sets whether an instance is in the desired state. + /// + [JsonPropertyName("_inDesiredState")] + [Description("Indicates whether an instance is in the desired state")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? InDesiredState { get; set; } + + /// + /// Generates a JSON representation of the resource object. + /// + /// + public JsonNode ToJson() + { + return JsonSerializer.SerializeToNode(this, GetType(), _options) ?? new JsonObject(); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/ISettingsResourceObject.cs b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/ISettingsResourceObject.cs new file mode 100644 index 0000000000..85c9c7eadc --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/ISettingsResourceObject.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Nodes; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace PowerToys.DSC.Models.ResourceObjects; + +/// +/// Interface for settings resource objects. +/// +public interface ISettingsResourceObject +{ + /// + /// Gets or sets the settings configuration. + /// + public ISettingsConfig SettingsInternal { get; set; } + + /// + /// Gets or sets whether an instance is in the desired state. + /// + public bool? InDesiredState { get; set; } + + /// + /// Generates a JSON representation of the resource object. + /// + /// String representation of the resource object in JSON format. + public JsonNode ToJson(); +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/SettingsResourceObject`1.cs b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/SettingsResourceObject`1.cs new file mode 100644 index 0000000000..d5017336ed --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/SettingsResourceObject`1.cs @@ -0,0 +1,34 @@ +// 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.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using NJsonSchema.Annotations; + +namespace PowerToys.DSC.Models.ResourceObjects; + +/// +/// Represents a settings resource object for a module's settings configuration. +/// +/// The type of the settings configuration. +public sealed class SettingsResourceObject : BaseResourceObject, ISettingsResourceObject + where TSettingsConfig : ISettingsConfig, new() +{ + public const string SettingsJsonPropertyName = "settings"; + + /// + /// Gets or sets the settings content for the module. + /// + [JsonPropertyName(SettingsJsonPropertyName)] + [Required] + [Description("The settings content for the module.")] + [JsonSchemaType(typeof(object))] + public TSettingsConfig Settings { get; set; } = new(); + + /// + [JsonIgnore] + public ISettingsConfig SettingsInternal { get => Settings; set => Settings = (TSettingsConfig)value; } +} diff --git a/src/dsc/v3/PowerToys.DSC/Options/InputOption.cs b/src/dsc/v3/PowerToys.DSC/Options/InputOption.cs new file mode 100644 index 0000000000..048c50a2df --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Options/InputOption.cs @@ -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.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.Text; +using System.Text.Json; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Options; + +/// +/// Represents an option for specifying JSON input for the dsc command. +/// +public sealed class InputOption : Option +{ + private static readonly CompositeFormat InvalidJsonInputError = CompositeFormat.Parse(Resources.InvalidJsonInputError); + + public InputOption() + : base("--input", Resources.InputOptionDescription) + { + AddValidator(OptionValidator); + } + + /// + /// Validates the JSON input provided to the option. + /// + /// The option result to validate. + private void OptionValidator(OptionResult result) + { + var value = result.GetValueOrDefault() ?? string.Empty; + if (string.IsNullOrEmpty(value)) + { + result.ErrorMessage = Resources.InputEmptyOrNullError; + } + else + { + try + { + JsonDocument.Parse(value); + } + catch (Exception e) + { + result.ErrorMessage = string.Format(CultureInfo.InvariantCulture, InvalidJsonInputError, e.Message); + } + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Options/ModuleOption.cs b/src/dsc/v3/PowerToys.DSC/Options/ModuleOption.cs new file mode 100644 index 0000000000..a5273c2cb0 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Options/ModuleOption.cs @@ -0,0 +1,19 @@ +// 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.CommandLine; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Options; + +/// +/// Represents an option for specifying the module name for the dsc command. +/// +public sealed class ModuleOption : Option +{ + public ModuleOption() + : base("--module", Resources.ModuleOptionDescription) + { + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Options/OutputDirectoryOption.cs b/src/dsc/v3/PowerToys.DSC/Options/OutputDirectoryOption.cs new file mode 100644 index 0000000000..7de1af64b7 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Options/OutputDirectoryOption.cs @@ -0,0 +1,43 @@ +// 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.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.IO; +using System.Text; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Options; + +/// +/// Represents an option for specifying the output directory for the dsc command. +/// +public sealed class OutputDirectoryOption : Option +{ + private static readonly CompositeFormat InvalidOutputDirectoryError = CompositeFormat.Parse(Resources.InvalidOutputDirectoryError); + + public OutputDirectoryOption() + : base("--outputDir", Resources.OutputDirectoryOptionDescription) + { + AddValidator(OptionValidator); + } + + /// + /// Validates the output directory option. + /// + /// The option result to validate. + private void OptionValidator(OptionResult result) + { + var value = result.GetValueOrDefault() ?? string.Empty; + if (string.IsNullOrEmpty(value)) + { + result.ErrorMessage = Resources.OutputDirectoryEmptyOrNullError; + } + else if (!Directory.Exists(value)) + { + result.ErrorMessage = string.Format(CultureInfo.InvariantCulture, InvalidOutputDirectoryError, value); + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Options/ResourceOption.cs b/src/dsc/v3/PowerToys.DSC/Options/ResourceOption.cs new file mode 100644 index 0000000000..cfce5dbfc7 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Options/ResourceOption.cs @@ -0,0 +1,43 @@ +// 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 System.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.Text; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Options; + +/// +/// Represents an option for specifying the resource name for the dsc command. +/// +public sealed class ResourceOption : Option +{ + private static readonly CompositeFormat InvalidResourceNameError = CompositeFormat.Parse(Resources.InvalidResourceNameError); + + private readonly IList _resources = []; + + public ResourceOption(IList resources) + : base("--resource", Resources.ResourceOptionDescription) + { + _resources = resources; + IsRequired = true; + AddValidator(OptionValidator); + } + + /// + /// Validates the resource option to ensure that the specified resource name is valid. + /// + /// The option result to validate. + private void OptionValidator(OptionResult result) + { + var value = result.GetValueOrDefault() ?? string.Empty; + if (!_resources.Contains(value)) + { + result.ErrorMessage = string.Format(CultureInfo.InvariantCulture, InvalidResourceNameError, string.Join(", ", _resources)); + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj new file mode 100644 index 0000000000..9dc11a0a8a --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj @@ -0,0 +1,50 @@ + + + + + + + Exe + ..\..\..\..\$(Platform)\$(Configuration) + false + false + PowerToys.DSC + PowerToys DSC + PowerToys.DSC + enable + + true + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + + + + + + \ No newline at end of file diff --git a/src/dsc/v3/PowerToys.DSC/Program.cs b/src/dsc/v3/PowerToys.DSC/Program.cs new file mode 100644 index 0000000000..09a22b64d6 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Program.cs @@ -0,0 +1,29 @@ +// 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.CommandLine; +using System.CommandLine.Parsing; +using System.Threading.Tasks; +using PowerToys.DSC.Commands; + +namespace PowerToys.DSC; + +/// +/// Main entry point for the PowerToys Desired State Configuration CLI application. +/// +public class Program +{ + public static async Task Main(string[] args) + { + var rootCommand = new RootCommand(Properties.Resources.PowerToysDSC); + rootCommand.AddCommand(new GetCommand()); + rootCommand.AddCommand(new SetCommand()); + rootCommand.AddCommand(new ExportCommand()); + rootCommand.AddCommand(new TestCommand()); + rootCommand.AddCommand(new SchemaCommand()); + rootCommand.AddCommand(new ManifestCommand()); + rootCommand.AddCommand(new ModulesCommand()); + return await rootCommand.InvokeAsync(args); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Properties/Resources.Designer.cs b/src/dsc/v3/PowerToys.DSC/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..4089d98c6b --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Properties/Resources.Designer.cs @@ -0,0 +1,234 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PowerToys.DSC.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PowerToys.DSC.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Get all state instances. + /// + internal static string ExportCommandDescription { + get { + return ResourceManager.GetString("ExportCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to write manifests to directory '{0}': {1}. + /// + internal static string FailedToWriteManifests { + get { + return ResourceManager.GetString("FailedToWriteManifests", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get the resource state. + /// + internal static string GetCommandDescription { + get { + return ResourceManager.GetString("GetCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Input cannot be empty or null. + /// + internal static string InputEmptyOrNullError { + get { + return ResourceManager.GetString("InputEmptyOrNullError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The JSON input. + /// + internal static string InputOptionDescription { + get { + return ResourceManager.GetString("InputOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid JSON input: {0}. + /// + internal static string InvalidJsonInputError { + get { + return ResourceManager.GetString("InvalidJsonInputError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid output directory: {0}. + /// + internal static string InvalidOutputDirectoryError { + get { + return ResourceManager.GetString("InvalidOutputDirectoryError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid resource name. Valid resource names are: {0}. + /// + internal static string InvalidResourceNameError { + get { + return ResourceManager.GetString("InvalidResourceNameError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get the manifest of the dsc resource. + /// + internal static string ManifestCommandDescription { + get { + return ResourceManager.GetString("ManifestCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Module '{0}' is not supported for the resource {1}. Use the 'module' command to list available modules.. + /// + internal static string ModuleNotSupportedByResource { + get { + return ResourceManager.GetString("ModuleNotSupportedByResource", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The module name. + /// + internal static string ModuleOptionDescription { + get { + return ResourceManager.GetString("ModuleOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get all supported modules for a specific resource. + /// + internal static string ModulesCommandDescription { + get { + return ResourceManager.GetString("ModulesCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Output directory cannot be empty or null. + /// + internal static string OutputDirectoryEmptyOrNullError { + get { + return ResourceManager.GetString("OutputDirectoryEmptyOrNullError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The output directory. + /// + internal static string OutputDirectoryOptionDescription { + get { + return ResourceManager.GetString("OutputDirectoryOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to PowerToys Desired State Configuration commands. + /// + internal static string PowerToysDSC { + get { + return ResourceManager.GetString("PowerToysDSC", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The resource name. + /// + internal static string ResourceOptionDescription { + get { + return ResourceManager.GetString("ResourceOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Outputs schema of the resource. + /// + internal static string SchemaCommandDescription { + get { + return ResourceManager.GetString("SchemaCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set the resource state. + /// + internal static string SetCommandDescription { + get { + return ResourceManager.GetString("SetCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Test the resource state. + /// + internal static string TestCommandDescription { + get { + return ResourceManager.GetString("TestCommandDescription", resourceCulture); + } + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Properties/Resources.resx b/src/dsc/v3/PowerToys.DSC/Properties/Resources.resx new file mode 100644 index 0000000000..2648d6501b --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Properties/Resources.resx @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + PowerToys Desired State Configuration commands + {Locked="PowerToys Desired State Configuration"} + + + Module '{0}' is not supported for the resource {1}. Use the 'module' command to list available modules. + {Locked="'module'","{0}","{1}"} + + + Get all state instances + + + Get the resource state + + + Get the manifest of the dsc resource + + + Get all supported modules for a specific resource + + + Outputs schema of the resource + + + Set the resource state + + + Test the resource state + + + Input cannot be empty or null + + + Failed to write manifests to directory '{0}': {1} + {Locked="{0}","{1}"} + + + The JSON input + + + The module name + + + The output directory + + + The resource name + + + Invalid JSON input: {0} + {Locked="{0}"} + + + Output directory cannot be empty or null + + + Invalid output directory: {0} + {Locked="{0}"} + + + Invalid resource name. Valid resource names are: {0} + {Locked="{0}"} + + \ No newline at end of file diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index 685eeaf350..4b77a6783f 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -1,11 +1,11 @@ - + - + @@ -26,6 +26,7 @@ + @@ -137,6 +138,16 @@ + + + + + + + + + + @@ -604,6 +615,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 7fe996abcc..1bfa55866d 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -1,7 +1,7 @@ - + PowerToys PowerToys @@ -33,6 +33,7 @@ PowerToys version 0.88.0 or later PowerToys version 0.89.0 or later PowerToys version 0.90.0 or later + PowerToys version 0.96.0 or later From PowerToys version 0.64.0 until PowerToys version 0.87.1 This policy configures the enabled state for all PowerToys utilities. @@ -245,6 +246,7 @@ If you don't configure this policy, the user will be able to control the setting Command Not Found: Configure enabled state CmdPal: Configure enabled state Crop And Lock: Configure enabled state + Light Switch: Configure enabled state Environment Variables: Configure enabled state FancyZones: Configure enabled state File Locksmith: Configure enabled state @@ -290,6 +292,54 @@ If you don't configure this policy, the user will be able to control the setting QOI file preview: Configure enabled state QOI file thumbnail: Configure enabled state Allow using online AI models + Advanced Paste: Allow OpenAI endpoint + This policy controls whether users can use the OpenAI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use OpenAI as their AI provider. + +If you disable this policy, users will not be able to select or use OpenAI endpoint in Advanced Paste settings. + Advanced Paste: Allow Azure OpenAI endpoint + This policy controls whether users can use the Azure OpenAI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Azure OpenAI as their AI provider. + +If you disable this policy, users will not be able to select or use Azure OpenAI endpoint in Advanced Paste settings. + Advanced Paste: Allow Azure AI Inference endpoint + This policy controls whether users can use the Azure AI Inference endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Azure AI Inference as their AI provider. + +If you disable this policy, users will not be able to select or use Azure AI Inference endpoint in Advanced Paste settings. + Advanced Paste: Allow Mistral endpoint + This policy controls whether users can use the Mistral AI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Mistral as their AI provider. + +If you disable this policy, users will not be able to select or use Mistral endpoint in Advanced Paste settings. + Advanced Paste: Allow Google endpoint + This policy controls whether users can use the Google (Gemini) endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Google as their AI provider. + +If you disable this policy, users will not be able to select or use Google endpoint in Advanced Paste settings. + Advanced Paste: Allow Anthropic endpoint + This policy controls whether users can use the Anthropic (Claude) endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Anthropic as their AI provider. + +If you disable this policy, users will not be able to select or use Anthropic endpoint in Advanced Paste settings. + Advanced Paste: Allow Ollama endpoint + This policy controls whether users can use the Ollama local model endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Ollama as their AI provider. + +If you disable this policy, users will not be able to select or use Ollama endpoint in Advanced Paste settings. + Advanced Paste: Allow Foundry Local endpoint + This policy controls whether users can use the Foundry Local model endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Foundry Local as their AI provider. + +If you disable this policy, users will not be able to select or use Foundry Local endpoint in Advanced Paste settings. Clipboard sharing enabled File transfer enabled Original user interface is available diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs new file mode 100644 index 0000000000..4446e24dde --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.UnitTests.Mocks; + +/// +/// Minimal implementation used by integration tests that +/// need to construct the runtime Advanced Paste services. +/// +internal sealed class IntegrationTestUserSettings : IUserSettings +{ + private readonly PasteAIConfiguration _configuration; + private readonly IReadOnlyList _customActions; + private readonly IReadOnlyList _additionalActions; + + public IntegrationTestUserSettings() + { + var provider = new PasteAIProviderDefinition + { + Id = "integration-openai", + EnableAdvancedAI = true, + ServiceTypeKind = AIServiceType.OpenAI, + ModelName = "gpt-4o", + ModerationEnabled = true, + }; + + _configuration = new PasteAIConfiguration + { + ActiveProviderId = provider.Id, + Providers = new ObservableCollection { provider }, + }; + + _customActions = Array.Empty(); + _additionalActions = Array.Empty(); + } + + public bool IsAIEnabled => true; + + public bool ShowCustomPreview => false; + + public bool CloseAfterLosingFocus => false; + + public bool EnableClipboardPreview => true; + + public IReadOnlyList CustomActions => _customActions; + + public IReadOnlyList AdditionalActions => _additionalActions; + + public PasteAIConfiguration PasteAIConfiguration => _configuration; + + public event EventHandler Changed; + + public Task SetActiveAIProviderAsync(string providerId) + { + _configuration.ActiveProviderId = providerId ?? string.Empty; + Changed?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs index 3782b057f1..17b8139bad 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs @@ -13,6 +13,8 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Services.OpenAI; using AdvancedPaste.UnitTests.Mocks; using ManagedCommon; @@ -79,7 +81,9 @@ public sealed class AIServiceBatchIntegrationTests Assert.IsTrue(results.Count <= inputs.Count); CollectionAssert.AreEqual(results.Select(result => result.ToInput()).ToList(), inputs.Take(results.Count).ToList()); + #pragma warning disable IL2026, IL3050 // The tests rely on runtime JSON serialization for ad-hoc data files. async Task WriteResultsAsync() => await File.WriteAllTextAsync(resultsFile, JsonSerializer.Serialize(results, SerializerOptions)); + #pragma warning restore IL2026, IL3050 Logger.LogInfo($"Starting {nameof(TestGenerateBatchResults)}; Count={inputs.Count}, InCache={results.Count}"); @@ -101,8 +105,12 @@ public sealed class AIServiceBatchIntegrationTests await WriteResultsAsync(); } - private static async Task> GetDataListAsync(string filePath) => - File.Exists(filePath) ? JsonSerializer.Deserialize>(await File.ReadAllTextAsync(filePath)) : []; + private static async Task> GetDataListAsync(string filePath) + { + #pragma warning disable IL2026, IL3050 // Tests only run locally and can depend on runtime JSON serialization. + return File.Exists(filePath) ? JsonSerializer.Deserialize>(await File.ReadAllTextAsync(filePath)) : []; + #pragma warning restore IL2026, IL3050 + } private static async Task GetTextOutputAsync(BatchTestInput input, PasteFormats format) { @@ -130,23 +138,35 @@ public sealed class AIServiceBatchIntegrationTests private static async Task GetOutputDataPackageAsync(BatchTestInput batchTestInput, PasteFormats format) { - VaultCredentialsProvider credentialsProvider = new(); - PromptModerationService promptModerationService = new(credentialsProvider); + var services = CreateServices(); NoOpProgress progress = new(); - CustomTextTransformService customTextTransformService = new(credentialsProvider, promptModerationService); switch (format) { case PasteFormats.CustomTextTransformation: - return DataPackageHelpers.CreateFromText(await customTextTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress)); + var transformResult = await services.CustomActionTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress); + return DataPackageHelpers.CreateFromText(transformResult.Content ?? string.Empty); case PasteFormats.KernelQuery: var clipboardData = DataPackageHelpers.CreateFromText(batchTestInput.Clipboard).GetView(); - KernelService kernelService = new(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, customTextTransformService); - return await kernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); + return await services.KernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); default: throw new InvalidOperationException($"Unexpected format {format}"); } } + + private static IntegrationTestServices CreateServices() + { + IntegrationTestUserSettings userSettings = new(); + EnhancedVaultCredentialsProvider credentialsProvider = new(userSettings); + PromptModerationService promptModerationService = new(credentialsProvider); + PasteAIProviderFactory providerFactory = new(); + ICustomActionTransformService customActionTransformService = new CustomActionTransformService(promptModerationService, providerFactory, credentialsProvider, userSettings); + IKernelService kernelService = new AdvancedAIKernelService(credentialsProvider, new NoOpKernelQueryCacheService(), promptModerationService, userSettings, customActionTransformService); + + return new IntegrationTestServices(customActionTransformService, kernelService); + } + + private readonly record struct IntegrationTestServices(ICustomActionTransformService CustomActionTransformService, IKernelService KernelService); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs index 998534cf5e..7c16089cd5 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs @@ -11,6 +11,8 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Services.OpenAI; using AdvancedPaste.Telemetry; using AdvancedPaste.UnitTests.Mocks; @@ -27,16 +29,19 @@ namespace AdvancedPaste.UnitTests.ServicesTests; public sealed class KernelServiceIntegrationTests : IDisposable { private const string StandardImageFile = "image_with_text_example.png"; - private KernelService _kernelService; + private IKernelService _kernelService; private AdvancedPasteEventListener _eventListener; [TestInitialize] public void TestInitialize() { - VaultCredentialsProvider credentialsProvider = new(); + IntegrationTestUserSettings userSettings = new(); + EnhancedVaultCredentialsProvider credentialsProvider = new(userSettings); PromptModerationService promptModerationService = new(credentialsProvider); + PasteAIProviderFactory providerFactory = new(); + CustomActionTransformService customActionTransformService = new(promptModerationService, providerFactory, credentialsProvider, userSettings); - _kernelService = new KernelService(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, new CustomTextTransformService(credentialsProvider, promptModerationService)); + _kernelService = new AdvancedAIKernelService(credentialsProvider, new NoOpKernelQueryCacheService(), promptModerationService, userSettings, customActionTransformService); _eventListener = new(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj index fba18de07c..1c80479c2d 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj @@ -33,11 +33,14 @@ + + + @@ -49,7 +52,6 @@ - @@ -57,10 +59,15 @@ + + + + + @@ -102,6 +109,7 @@ + @@ -114,9 +122,38 @@ true + + + MSBuild:Compile + + MSBuild:Compile + + + MSBuild:Compile + + + + + + + Assets\Settings\Icons\Models\%(Filename)%(Extension) + PreserveNewest + + + + PreserveNewest + + + PreserveNewest + + + + PreserveNewest + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml index 1ffabf92a0..df6ed811ac 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml @@ -9,159 +9,10 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs index 3ac3baa9d0..3fa940952e 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs @@ -10,10 +10,10 @@ using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Settings; using AdvancedPaste.ViewModels; using ManagedCommon; @@ -77,11 +77,12 @@ namespace AdvancedPaste { services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }).Build(); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml index f03a579821..a250fdffdc 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml @@ -11,7 +11,7 @@ - + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml new file mode 100644 index 0000000000..e008d35a9a --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs new file mode 100644 index 0000000000..a28c87ac61 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace AdvancedPaste.Controls +{ + public sealed partial class ClipboardHistoryItemPreviewControl : UserControl + { + public static readonly DependencyProperty ClipboardItemProperty = DependencyProperty.Register( + nameof(ClipboardItem), + typeof(ClipboardItem), + typeof(ClipboardHistoryItemPreviewControl), + new PropertyMetadata(defaultValue: null, OnClipboardItemChanged)); + + public ClipboardItem ClipboardItem + { + get => (ClipboardItem)GetValue(ClipboardItemProperty); + set => SetValue(ClipboardItemProperty, value); + } + + // Computed properties for display + public string Header => ClipboardItem != null ? GetHeaderFromFormat(ClipboardItem.Format) : string.Empty; + + public string IconGlyph => ClipboardItem != null ? GetGlyphFromFormat(ClipboardItem.Format) : string.Empty; + + public string ContentText => ClipboardItem?.Content ?? string.Empty; + + public ImageSource ContentImage => ClipboardItem?.Image; + + public DateTimeOffset? Timestamp => ClipboardItem?.Timestamp ?? ClipboardItem?.Item?.Timestamp; + + public bool HasImage => ContentImage is not null; + + public bool HasText => !string.IsNullOrEmpty(ContentText) && !HasImage; + + public bool HasGlyph => !HasImage && !HasText && !string.IsNullOrEmpty(IconGlyph); + + public ClipboardHistoryItemPreviewControl() + { + InitializeComponent(); + } + + private static void OnClipboardItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is ClipboardHistoryItemPreviewControl control) + { + // Notify bindings that all computed properties may have changed + control.Bindings.Update(); + } + } + + private static string GetHeaderFromFormat(ClipboardFormat format) + { + // Check flags in priority order (most specific first) + if (format.HasFlag(ClipboardFormat.Image)) + { + return GetStringOrFallback("ClipboardPreviewCategoryImage", "Image"); + } + + if (format.HasFlag(ClipboardFormat.Video)) + { + return GetStringOrFallback("ClipboardPreviewCategoryVideo", "Video"); + } + + if (format.HasFlag(ClipboardFormat.Audio)) + { + return GetStringOrFallback("ClipboardPreviewCategoryAudio", "Audio"); + } + + if (format.HasFlag(ClipboardFormat.File)) + { + return GetStringOrFallback("ClipboardPreviewCategoryFile", "File"); + } + + if (format.HasFlag(ClipboardFormat.Text) || format.HasFlag(ClipboardFormat.Html)) + { + return GetStringOrFallback("ClipboardPreviewCategoryText", "Text"); + } + + return GetStringOrFallback("ClipboardPreviewCategoryUnknown", "Clipboard"); + } + + private static string GetGlyphFromFormat(ClipboardFormat format) + { + // Check flags in priority order (most specific first) + if (format.HasFlag(ClipboardFormat.Image)) + { + return "\uEB9F"; // Image icon + } + + if (format.HasFlag(ClipboardFormat.Video)) + { + return "\uE714"; // Video icon + } + + if (format.HasFlag(ClipboardFormat.Audio)) + { + return "\uE189"; // Audio icon + } + + if (format.HasFlag(ClipboardFormat.File)) + { + return "\uE8A5"; // File icon + } + + if (format.HasFlag(ClipboardFormat.Text) || format.HasFlag(ClipboardFormat.Html)) + { + return "\uE8D2"; // Text icon + } + + return "\uE77B"; // Generic clipboard icon + } + + private static string GetStringOrFallback(string resourceKey, string fallback) + { + var value = ResourceLoaderInstance.ResourceLoader.GetString(resourceKey); + return string.IsNullOrEmpty(value) ? fallback : value; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index dd09c717b0..6303564d9b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -7,34 +7,21 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:AdvancedPaste.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:settings="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" + x:Name="PromptBoxControl" mc:Ignorable="d"> - - - #65C8F2 - - - - - - - - #005FB8 - - - - - - - - #48B1E9 - - - + + + + + + + 44 + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png b/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png index 73621edfc0..78a9a18606 100644 Binary files a/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png and b/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png differ diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs index 1ab58bf269..b74192213b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs @@ -18,6 +18,7 @@ namespace AdvancedPaste.Helpers PromptTokens = semanticKernelFormatEvent.PromptTokens; CompletionTokens = semanticKernelFormatEvent.CompletionTokens; ModelName = semanticKernelFormatEvent.ModelName; + ProviderType = semanticKernelFormatEvent.ProviderType; ActionChain = semanticKernelFormatEvent.ActionChain; } @@ -38,6 +39,8 @@ namespace AdvancedPaste.Helpers public string ModelName { get; set; } + public string ProviderType { get; set; } + public string ActionChain { get; set; } public string ToJsonString() => JsonSerializer.Serialize(this, SourceGenerationContext.Default.AIServiceFormatEvent); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs new file mode 100644 index 0000000000..ba7d33f4fb --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs @@ -0,0 +1,54 @@ +// 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 AdvancedPaste.Models; +using Microsoft.SemanticKernel; + +namespace AdvancedPaste.Helpers; + +/// +/// Helper class for extracting AI service usage information from chat messages. +/// +public static class AIServiceUsageHelper +{ + /// + /// Extracts AI service usage information from OpenAI chat message metadata. + /// + /// The chat message containing usage metadata. + /// AI service usage information or AIServiceUsage.None if extraction fails. + public static AIServiceUsage GetOpenAIServiceUsage(ChatMessageContent chatMessage) + { + // Try to get usage information from metadata + if (chatMessage.Metadata?.TryGetValue("Usage", out var usageObj) == true) + { + // Handle different possible usage types through reflection to be version-agnostic + var usageType = usageObj.GetType(); + + try + { + // Try common property names for prompt tokens + var promptTokensProp = usageType.GetProperty("PromptTokens") ?? + usageType.GetProperty("InputTokens") ?? + usageType.GetProperty("InputTokenCount"); + + var completionTokensProp = usageType.GetProperty("CompletionTokens") ?? + usageType.GetProperty("OutputTokens") ?? + usageType.GetProperty("OutputTokenCount"); + + if (promptTokensProp != null && completionTokensProp != null) + { + var promptTokens = (int)(promptTokensProp.GetValue(usageObj) ?? 0); + var completionTokens = (int)(completionTokensProp.GetValue(usageObj) ?? 0); + return new AIServiceUsage(promptTokens, completionTokens); + } + } + catch + { + // If reflection fails, fall back to no usage + } + } + + return AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs new file mode 100644 index 0000000000..9f824d3399 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.ApplicationModel.DataTransfer; + +namespace AdvancedPaste.Helpers +{ + internal static class ClipboardItemHelper + { + /// + /// Creates a ClipboardItem from current clipboard data. + /// + public static async Task CreateFromCurrentClipboardAsync( + DataPackageView clipboardData, + ClipboardFormat availableFormats, + DateTimeOffset? timestamp = null, + BitmapImage existingImage = null) + { + if (clipboardData == null || availableFormats == ClipboardFormat.None) + { + return null; + } + + var clipboardItem = new ClipboardItem + { + Format = availableFormats, + Timestamp = timestamp, + }; + + // Text or HTML content + if (availableFormats.HasFlag(ClipboardFormat.Text) || availableFormats.HasFlag(ClipboardFormat.Html)) + { + clipboardItem.Content = await clipboardData.GetTextOrEmptyAsync(); + } + + // Image content + else if (availableFormats.HasFlag(ClipboardFormat.Image)) + { + // Reuse existing image if provided + if (existingImage != null) + { + clipboardItem.Image = existingImage; + } + else + { + clipboardItem.Image = await TryCreateBitmapImageAsync(clipboardData); + } + } + + return clipboardItem; + } + + /// + /// Creates a BitmapImage from clipboard data. + /// + private static async Task TryCreateBitmapImageAsync(DataPackageView clipboardData) + { + try + { + var imageReference = await clipboardData.GetBitmapAsync(); + if (imageReference != null) + { + using (var imageStream = await imageReference.OpenReadAsync()) + { + var bitmapImage = new BitmapImage(); + await bitmapImage.SetSourceAsync(imageStream); + return bitmapImage; + } + } + } + catch + { + // Silently fail - caller can check for null + } + + return null; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs index 529773f9a6..2cd7554a50 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Models; using ManagedCommon; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.Data.Html; @@ -180,6 +182,46 @@ internal static class DataPackageHelpers } } + internal static async Task GetClipboardTextOrThrowAsync(this DataPackageView dataPackageView, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dataPackageView); + + try + { + if (dataPackageView.Contains(StandardDataFormats.Text)) + { + return await dataPackageView.GetTextAsync(); + } + + if (dataPackageView.Contains(StandardDataFormats.Html)) + { + var html = await dataPackageView.GetHtmlFormatAsync(); + return HtmlUtilities.ConvertToText(html); + } + + if (dataPackageView.Contains(StandardDataFormats.Bitmap)) + { + var bitmap = await dataPackageView.GetImageContentAsync(); + if (bitmap != null) + { + return await OcrHelpers.ExtractTextAsync(bitmap, cancellationToken); + } + } + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + throw CreateClipboardTextMissingException(ex); + } + + throw CreateClipboardTextMissingException(); + } + + private static PasteActionException CreateClipboardTextMissingException(Exception innerException = null) + { + var message = ResourceLoaderInstance.ResourceLoader.GetString("ClipboardEmptyWarning"); + return new PasteActionException(message, innerException ?? new InvalidOperationException("Clipboard does not contain text content.")); + } + internal static async Task GetHtmlContentAsync(this DataPackageView dataPackageView) => dataPackageView.Contains(StandardDataFormats.Html) ? await dataPackageView.GetHtmlFormatAsync() : string.Empty; @@ -195,6 +237,22 @@ internal static class DataPackageHelpers return null; } + internal static async Task GetPreviewBitmapAsync(this DataPackageView dataPackageView) + { + var stream = await dataPackageView.GetImageStreamAsync(); + if (stream == null) + { + return null; + } + + using (stream) + { + var bitmapImage = new BitmapImage(); + bitmapImage.SetSource(stream); + return bitmapImage; + } + } + private static async Task GetImageStreamAsync(this DataPackageView dataPackageView) { if (dataPackageView.Contains(StandardDataFormats.StorageItems)) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs index 105fe2c0d8..d692263dc1 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using AdvancedPaste.Models; using Microsoft.PowerToys.Settings.UI.Library; @@ -12,16 +13,22 @@ namespace AdvancedPaste.Settings { public interface IUserSettings { - public bool IsAdvancedAIEnabled { get; } + public bool IsAIEnabled { get; } public bool ShowCustomPreview { get; } public bool CloseAfterLosingFocus { get; } + public bool EnableClipboardPreview { get; } + public IReadOnlyList CustomActions { get; } public IReadOnlyList AdditionalActions { get; } + public PasteAIConfiguration PasteAIConfiguration { get; } + public event EventHandler Changed; + + Task SetActiveAIProviderAsync(string providerId); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 8a25b70f07..59f31f0e99 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -13,6 +13,7 @@ using AdvancedPaste.Models; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Utilities; +using Windows.Security.Credentials; namespace AdvancedPaste.Settings { @@ -33,23 +34,29 @@ namespace AdvancedPaste.Settings public event EventHandler Changed; - public bool IsAdvancedAIEnabled { get; private set; } + public bool IsAIEnabled { get; private set; } public bool ShowCustomPreview { get; private set; } public bool CloseAfterLosingFocus { get; private set; } + public bool EnableClipboardPreview { get; private set; } + public IReadOnlyList AdditionalActions => _additionalActions; public IReadOnlyList CustomActions => _customActions; + public PasteAIConfiguration PasteAIConfiguration { get; private set; } + public UserSettings(IFileSystem fileSystem) { _settingsUtils = new SettingsUtils(fileSystem); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + EnableClipboardPreview = true; + PasteAIConfiguration = new PasteAIConfiguration(); _additionalActions = []; _customActions = []; _taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); @@ -94,13 +101,17 @@ namespace AdvancedPaste.Settings var settings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteModuleName); if (settings != null) { + bool migratedLegacyEnablement = TryMigrateLegacyAIEnablement(settings); + void UpdateSettings() { var properties = settings.Properties; - IsAdvancedAIEnabled = properties.IsAdvancedAIEnabled; + IsAIEnabled = properties.IsAIEnabled; ShowCustomPreview = properties.ShowCustomPreview; CloseAfterLosingFocus = properties.CloseAfterLosingFocus; + EnableClipboardPreview = properties.EnableClipboardPreview; + PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration(); var sourceAdditionalActions = properties.AdditionalActions; (PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats = @@ -126,6 +137,11 @@ namespace AdvancedPaste.Settings Task.Factory .StartNew(UpdateSettings, CancellationToken.None, TaskCreationOptions.None, _taskScheduler) .Wait(); + + if (migratedLegacyEnablement) + { + settings.Save(_settingsUtils); + } } retry = false; @@ -144,6 +160,220 @@ namespace AdvancedPaste.Settings } } + private static bool TryMigrateLegacyAIEnablement(AdvancedPasteSettings settings) + { + if (settings?.Properties is null) + { + return false; + } + + var properties = settings.Properties; + bool legacyAdvancedAIConsumed = properties.TryConsumeLegacyAdvancedAIEnabled(out var advancedFlag); + bool legacyAdvancedAIEnabled = legacyAdvancedAIConsumed && advancedFlag; + PasswordCredential legacyCredential = TryGetLegacyOpenAICredential(); + + if (legacyCredential is null) + { + return legacyAdvancedAIConsumed; + } + + var configuration = properties.PasteAIConfiguration; + + if (configuration is null) + { + configuration = new PasteAIConfiguration(); + properties.PasteAIConfiguration = configuration; + } + + bool configurationUpdated = false; + + var ensureResult = AdvancedPasteMigrationHelper.EnsureOpenAIProvider(configuration); + PasteAIProviderDefinition openAIProvider = ensureResult.Provider; + configurationUpdated |= ensureResult.Updated; + + if (legacyAdvancedAIConsumed && openAIProvider is not null && openAIProvider.EnableAdvancedAI != legacyAdvancedAIEnabled) + { + openAIProvider.EnableAdvancedAI = legacyAdvancedAIEnabled; + configurationUpdated = true; + } + + if (openAIProvider is not null) + { + StoreMigratedOpenAICredential(openAIProvider.Id, openAIProvider.ServiceType, legacyCredential.Password); + RemoveLegacyOpenAICredential(); + } + + const bool shouldEnableAI = true; + bool enabledUpdated = false; + if (properties.IsAIEnabled != shouldEnableAI) + { + properties.IsAIEnabled = shouldEnableAI; + enabledUpdated = true; + } + + return configurationUpdated || enabledUpdated || legacyAdvancedAIConsumed; + } + + private static PasswordCredential TryGetLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + var credential = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + credential?.RetrievePassword(); + return credential; + } + catch (Exception) + { + return null; + } + } + + private static void RemoveLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + TryRemoveCredential(vault, "https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + } + catch (Exception) + { + } + } + + private static void StoreMigratedOpenAICredential(string providerId, string serviceType, string password) + { + if (string.IsNullOrWhiteSpace(password)) + { + return; + } + + try + { + var serviceKind = serviceType.ToAIServiceType(); + if (serviceKind != AIServiceType.OpenAI) + { + return; + } + + string resource = "https://platform.openai.com/api-keys"; + string username = $"PowerToys_AdvancedPaste_PasteAI_openai_{NormalizeProviderIdentifier(providerId)}"; + + PasswordVault vault = new(); + TryRemoveCredential(vault, resource, username); + + PasswordCredential credential = new(resource, username, password); + vault.Add(credential); + } + catch (Exception ex) + { + Logger.LogError("Failed to migrate legacy OpenAI credential", ex); + } + } + + private static void TryRemoveCredential(PasswordVault vault, string credentialResource, string credentialUserName) + { + try + { + PasswordCredential existingCred = vault.Retrieve(credentialResource, credentialUserName); + vault.Remove(existingCred); + } + catch (Exception) + { + // Credential doesn't exist, which is fine + } + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } + + public async Task SetActiveAIProviderAsync(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return; + } + + await Task.Run(() => + { + lock (_loadingSettingsLock) + { + var settings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteModuleName); + var configuration = settings?.Properties?.PasteAIConfiguration; + var providers = configuration?.Providers; + + if (configuration == null || providers == null || providers.Count == 0) + { + return; + } + + var target = providers.FirstOrDefault(provider => string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase)); + if (target == null) + { + return; + } + + if (string.Equals(configuration.ActiveProvider?.Id, providerId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + configuration.ActiveProviderId = providerId; + + foreach (var provider in providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + + try + { + settings.Save(_settingsUtils); + } + catch (Exception ex) + { + Logger.LogError("Failed to set active AI provider", ex); + return; + } + + try + { + Task.Factory + .StartNew( + () => + { + PasteAIConfiguration.ActiveProviderId = providerId; + + if (PasteAIConfiguration.Providers is not null) + { + foreach (var provider in PasteAIConfiguration.Providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + } + + Changed?.Invoke(this, EventArgs.Empty); + }, + CancellationToken.None, + TaskCreationOptions.None, + _taskScheduler) + .Wait(); + } + catch (Exception ex) + { + Logger.LogError("Failed to dispatch active AI provider change", ex); + } + } + }); + } + public void Dispose() { Dispose(true); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs index 1013108bc9..16814e7001 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs @@ -2,6 +2,7 @@ // 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 AdvancedPaste.Helpers; using Microsoft.UI.Xaml.Media.Imaging; using Windows.ApplicationModel.DataTransfer; @@ -12,10 +13,15 @@ public class ClipboardItem { public string Content { get; set; } - public ClipboardHistoryItem Item { get; set; } - public BitmapImage Image { get; set; } + public ClipboardFormat Format { get; set; } + + public DateTimeOffset? Timestamp { get; set; } + + // Only used for clipboard history items that have a ClipboardHistoryItem + public ClipboardHistoryItem Item { get; set; } + public string Description => !string.IsNullOrEmpty(Content) ? Content : Image is not null ? ResourceLoaderInstance.ResourceLoader.GetString("ClipboardHistoryImage") : string.Empty; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs new file mode 100644 index 0000000000..759d6ec57d --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs @@ -0,0 +1,221 @@ +// 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.Linq; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services; + +public sealed class AdvancedAIKernelService : KernelServiceBase +{ + private sealed record RuntimeConfiguration( + AIServiceType ServiceType, + string ModelName, + string Endpoint, + string DeploymentName, + string ModelPath, + string SystemPrompt, + bool ModerationEnabled) : IKernelRuntimeConfiguration; + + private readonly IAICredentialsProvider credentialsProvider; + + public AdvancedAIKernelService( + IAICredentialsProvider credentialsProvider, + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) + : base(queryCacheService, promptModerationService, userSettings, customActionTransformService) + { + ArgumentNullException.ThrowIfNull(credentialsProvider); + + this.credentialsProvider = credentialsProvider; + } + + protected override string AdvancedAIModelName => GetRuntimeConfiguration().ModelName; + + protected override PromptExecutionSettings PromptExecutionSettings => CreatePromptExecutionSettings(); + + protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) + { + ArgumentNullException.ThrowIfNull(kernelBuilder); + + var runtimeConfig = GetRuntimeConfiguration(); + var serviceType = runtimeConfig.ServiceType; + var modelName = runtimeConfig.ModelName; + var requiresApiKey = RequiresApiKey(serviceType); + var apiKey = string.Empty; + if (requiresApiKey) + { + this.credentialsProvider.Refresh(); + apiKey = (this.credentialsProvider.GetKey() ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"An API key is required for {serviceType} but none was found in the credential vault."); + } + } + + var endpoint = string.IsNullOrWhiteSpace(runtimeConfig.Endpoint) ? null : runtimeConfig.Endpoint.Trim(); + var deployment = string.IsNullOrWhiteSpace(runtimeConfig.DeploymentName) ? modelName : runtimeConfig.DeploymentName; + + switch (serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(modelName, apiKey, serviceId: modelName); + break; + case AIServiceType.AzureOpenAI: + kernelBuilder.AddAzureOpenAIChatCompletion(deployment, RequireEndpoint(endpoint, serviceType), apiKey, serviceId: modelName); + break; + default: + throw new NotSupportedException($"Service type '{runtimeConfig.ServiceType}' is not supported"); + } + } + + protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) + { + return AIServiceUsageHelper.GetOpenAIServiceUsage(chatMessage); + } + + protected override bool ShouldModerateAdvancedAI() + { + if (!TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return false; + } + + return runtimeConfig.ModerationEnabled && (runtimeConfig.ServiceType == AIServiceType.OpenAI || runtimeConfig.ServiceType == AIServiceType.AzureOpenAI); + } + + private static string GetModelName(PasteAIProviderDefinition config) + { + if (!string.IsNullOrWhiteSpace(config?.ModelName)) + { + return config.ModelName; + } + + return "gpt-4o"; + } + + protected override IKernelRuntimeConfiguration GetRuntimeConfiguration() + { + if (TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return runtimeConfig; + } + + throw new InvalidOperationException("No Advanced AI provider is configured."); + } + + private bool TryGetRuntimeConfiguration(out IKernelRuntimeConfiguration runtimeConfig) + { + runtimeConfig = null; + + if (!TryResolveAdvancedProvider(out var provider)) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + if (!IsServiceTypeSupported(serviceType)) + { + return false; + } + + runtimeConfig = new RuntimeConfiguration( + serviceType, + GetModelName(provider), + provider.EndpointUrl, + provider.DeploymentName, + provider.ModelPath, + provider.SystemPrompt, + provider.ModerationEnabled); + return true; + } + + private bool TryResolveAdvancedProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = this.UserSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedProvider(PasteAIProviderDefinition provider) + { + if (provider is null || !provider.EnableAdvancedAI) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + return IsServiceTypeSupported(serviceType); + } + + private static bool IsServiceTypeSupported(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI or AIServiceType.AzureOpenAI; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return true; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} configuration but was not provided."); + } + + private PromptExecutionSettings CreatePromptExecutionSettings() + { + var serviceType = GetRuntimeConfiguration().ServiceType; + return new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(), + Temperature = 0.01, + }; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs new file mode 100644 index 0000000000..562ea3976c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformResult + { + public CustomActionTransformResult(string content, AIServiceUsage usage) + { + Content = content; + Usage = usage; + } + + public string Content { get; } + + public AIServiceUsage Usage { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs new file mode 100644 index 0000000000..57d55492a4 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs @@ -0,0 +1,205 @@ +// 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.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using AdvancedPaste.Telemetry; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformService : ICustomActionTransformService + { + private const string DefaultSystemPrompt = """ + You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. + Do not output anything else besides the reformatted clipboard content. + """; + + private readonly IPromptModerationService promptModerationService; + private readonly IPasteAIProviderFactory providerFactory; + private readonly IAICredentialsProvider credentialsProvider; + private readonly IUserSettings userSettings; + + public CustomActionTransformService(IPromptModerationService promptModerationService, IPasteAIProviderFactory providerFactory, IAICredentialsProvider credentialsProvider, IUserSettings userSettings) + { + this.promptModerationService = promptModerationService; + this.providerFactory = providerFactory; + this.credentialsProvider = credentialsProvider; + this.userSettings = userSettings; + } + + public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) + { + var pasteConfig = userSettings?.PasteAIConfiguration; + var providerConfig = BuildProviderConfig(pasteConfig); + + return await TransformAsync(prompt, inputText, providerConfig, cancellationToken, progress); + } + + private async Task TransformAsync(string prompt, string inputText, PasteAIConfig providerConfig, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(providerConfig); + + if (string.IsNullOrWhiteSpace(prompt)) + { + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + if (string.IsNullOrWhiteSpace(inputText)) + { + Logger.LogWarning("Clipboard has no usable text data"); + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + var systemPrompt = providerConfig.SystemPrompt ?? DefaultSystemPrompt; + + var fullPrompt = (systemPrompt ?? string.Empty) + "\n\n" + (inputText ?? string.Empty); + + if (ShouldModerate(providerConfig)) + { + await promptModerationService.ValidateAsync(fullPrompt, cancellationToken); + } + + try + { + var provider = providerFactory.CreateProvider(providerConfig); + + var request = new PasteAIRequest + { + Prompt = prompt, + InputText = inputText, + SystemPrompt = systemPrompt, + }; + + var operationStart = DateTime.UtcNow; + + var providerContent = await provider.ProcessPasteAsync( + request, + cancellationToken, + progress); + + var durationMs = (int)Math.Round((DateTime.UtcNow - operationStart).TotalMilliseconds); + + var usage = request.Usage; + var content = providerContent ?? string.Empty; + + // Log endpoint usage (custom action pipeline is not the advanced SK flow) + var endpointEvent = new AdvancedPasteEndpointUsageEvent(providerConfig.ProviderType, providerConfig.Model ?? string.Empty, isAdvanced: false, durationMs: durationMs); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + + Logger.LogDebug($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} complete; ModelName={providerConfig.Model ?? string.Empty}, PromptTokens={usage.PromptTokens}, CompletionTokens={usage.CompletionTokens}, DurationMs={durationMs}"); + + return new CustomActionTransformResult(content, usage); + } + catch (Exception ex) + { + Logger.LogError($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} failed", ex); + var statusCode = ExtractStatusCode(ex); + var modelName = providerConfig.Model ?? string.Empty; + AdvancedPasteCustomActionErrorEvent errorEvent = new(providerConfig.ProviderType, modelName, statusCode, ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); + PowerToysTelemetry.Log.WriteEvent(errorEvent); + + if (ex is PasteActionException or OperationCanceledException) + { + throw; + } + + var failureMessage = providerConfig.ProviderType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => ErrorHelpers.TranslateErrorText(statusCode), + _ => ResourceLoaderInstance.ResourceLoader.GetString("PasteError"), + }; + + throw new PasteActionException(failureMessage, ex); + } + } + + private static int ExtractStatusCode(Exception exception) + { + if (exception is HttpOperationException httpOperationException) + { + return (int?)httpOperationException.StatusCode ?? -1; + } + + if (exception is HttpRequestException httpRequestException && httpRequestException.StatusCode is HttpStatusCode statusCode) + { + return (int)statusCode; + } + + return -1; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config) + { + config ??= new PasteAIConfiguration(); + var provider = config.ActiveProvider ?? config.Providers?.FirstOrDefault() ?? new PasteAIProviderDefinition(); + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + var systemPrompt = string.IsNullOrWhiteSpace(provider.SystemPrompt) ? DefaultSystemPrompt : provider.SystemPrompt; + var apiKey = AcquireApiKey(serviceType); + var modelName = provider.ModelName; + + var providerConfig = new PasteAIConfig + { + ProviderType = serviceType, + ApiKey = apiKey, + Model = modelName, + Endpoint = provider.EndpointUrl, + DeploymentName = provider.DeploymentName, + LocalModelPath = provider.ModelPath, + ModelPath = provider.ModelPath, + SystemPrompt = systemPrompt, + ModerationEnabled = provider.ModerationEnabled, + }; + + return providerConfig; + } + + private string AcquireApiKey(AIServiceType serviceType) + { + if (!RequiresApiKey(serviceType)) + { + return string.Empty; + } + + credentialsProvider.Refresh(); + return credentialsProvider.GetKey() ?? string.Empty; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Onnx => false, + AIServiceType.Ollama => false, + _ => true, + }; + } + + private static bool ShouldModerate(PasteAIConfig providerConfig) + { + if (providerConfig is null || !providerConfig.ModerationEnabled) + { + return false; + } + + return providerConfig.ProviderType == AIServiceType.OpenAI || providerConfig.ProviderType == AIServiceType.AzureOpenAI; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs new file mode 100644 index 0000000000..43481eddae --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using LanguageModelProvider; +using Microsoft.Extensions.AI; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions; + +public sealed class FoundryLocalPasteProvider : IPasteAIProvider +{ + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.FoundryLocal, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new FoundryLocalPasteProvider(config)); + + private static readonly FoundryLocalModelProvider _modelProvider = FoundryLocalModelProvider.Instance; + + private readonly PasteAIConfig _config; + + public FoundryLocalPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + } + + public async Task IsAvailableAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return await FoundryLocalModelProvider.Instance.IsAvailable().ConfigureAwait(false); + } + + public async Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new PasteActionException( + "System prompt is required for Foundry Local", + new ArgumentException("System prompt must be provided", nameof(request))); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new PasteActionException( + "Prompt and input text are required", + new ArgumentException("Prompt and input text must be provided", nameof(request))); + } + + var modelReference = _config?.Model; + if (string.IsNullOrWhiteSpace(modelReference)) + { + throw new PasteActionException( + "No Foundry Local model selected", + new InvalidOperationException("Model identifier is required"), + aiServiceMessage: "Please select a model in the AI provider settings."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + IChatClient chatClient; + try + { + chatClient = _modelProvider.GetIChatClient(modelReference); + } + catch (InvalidOperationException ex) + { + // GetIChatClient throws InvalidOperationException for user-facing errors + var errorMessage = string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("FoundryLocal_UnableToLoadModel"), modelReference); + throw new PasteActionException( + errorMessage, + ex, + aiServiceMessage: ex.Message); + } + + var userMessageContent = $""" + User instructions: + {prompt} + + Text: + {inputText} + + Output: + """; + + var chatMessages = new List + { + new(ChatRole.System, systemPrompt), + new(ChatRole.User, userMessageContent), + }; + + var chatOptions = CreateChatOptions(_config?.SystemPrompt, modelReference); + + progress?.Report(0.1); + + var response = await chatClient.GetResponseAsync(chatMessages, chatOptions, cancellationToken).ConfigureAwait(false); + + progress?.Report(0.8); + + var responseText = GetResponseText(response); + request.Usage = ToUsage(response.Usage); + + progress?.Report(1.0); + + return responseText ?? string.Empty; + } + catch (OperationCanceledException) + { + // Let cancellation exceptions pass through unchanged + throw; + } + catch (PasteActionException) + { + // Let our custom exceptions pass through unchanged + throw; + } + catch (Exception ex) + { + // Wrap any other exceptions with context + var modelInfo = !string.IsNullOrWhiteSpace(_config?.Model) ? $" (Model: {_config.Model})" : string.Empty; + throw new PasteActionException( + $"Failed to generate response using Foundry Local{modelInfo}", + ex, + aiServiceMessage: $"Error details: {ex.Message}"); + } + } + + private static ChatOptions CreateChatOptions(string systemPrompt, string modelReference) + { + var options = new ChatOptions + { + ModelId = modelReference, + }; + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + options.Instructions = systemPrompt; + } + + return options; + } + + private static string GetResponseText(ChatResponse response) + { + if (!string.IsNullOrWhiteSpace(response.Text)) + { + return response.Text; + } + + if (response.Messages is { Count: > 0 }) + { + var lastMessage = response.Messages.LastOrDefault(m => !string.IsNullOrWhiteSpace(m.Text)); + if (!string.IsNullOrWhiteSpace(lastMessage?.Text)) + { + return lastMessage.Text; + } + } + + return string.Empty; + } + + private static AIServiceUsage ToUsage(UsageDetails usageDetails) + { + if (usageDetails is null) + { + return AIServiceUsage.None; + } + + int promptTokens = (int)(usageDetails.InputTokenCount ?? 0); + int completionTokens = (int)(usageDetails.OutputTokenCount ?? 0); + + if (promptTokens == 0 && completionTokens == 0) + { + return AIServiceUsage.None; + } + + return new AIServiceUsage(promptTokens, completionTokens); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs new file mode 100644 index 0000000000..1c3ecb980c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +using AdvancedPaste.Settings; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface ICustomActionTransformService + { + Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs new file mode 100644 index 0000000000..764d99f942 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProvider + { + Task IsAvailableAsync(CancellationToken cancellationToken); + + Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs new file mode 100644 index 0000000000..aacc61bec9 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs @@ -0,0 +1,11 @@ +// 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 AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProviderFactory + { + IPasteAIProvider CreateProvider(PasteAIConfig config); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs new file mode 100644 index 0000000000..f4d45ccd74 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class LocalModelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.Onnx, + AIServiceType.ML, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new LocalModelPasteProvider(config)); + + private readonly PasteAIConfig _config; + + public LocalModelPasteProvider(PasteAIConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + public Task IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + // TODO: Implement local model inference logic using _config.LocalModelPath/_config.ModelPath + var content = request.InputText ?? string.Empty; + request.Usage = AIServiceUsage.None; + return Task.FromResult(content); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs new file mode 100644 index 0000000000..1d8a60f041 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace AdvancedPaste.Services.CustomActions +{ + public class PasteAIConfig + { + public AIServiceType ProviderType { get; set; } + + public string Model { get; set; } + + public string ApiKey { get; set; } + + public string Endpoint { get; set; } + + public string DeploymentName { get; set; } + + public string LocalModelPath { get; set; } + + public string ModelPath { get; set; } + + public string SystemPrompt { get; set; } + + public bool ModerationEnabled { get; set; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs new file mode 100644 index 0000000000..7339b4e4e3 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderFactory : IPasteAIProviderFactory + { + private static readonly IReadOnlyList ProviderRegistrations = new[] + { + SemanticKernelPasteProvider.Registration, + LocalModelPasteProvider.Registration, + FoundryLocalPasteProvider.Registration, + }; + + private static readonly IReadOnlyDictionary> ProviderFactories = CreateProviderFactories(); + + public IPasteAIProvider CreateProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + var serviceType = config.ProviderType; + if (serviceType == AIServiceType.Unknown) + { + serviceType = AIServiceType.OpenAI; + config.ProviderType = serviceType; + } + + if (!ProviderFactories.TryGetValue(serviceType, out var factory)) + { + throw new NotSupportedException($"Provider {config.ProviderType} not supported"); + } + + return factory(config); + } + + private static IReadOnlyDictionary> CreateProviderFactories() + { + var map = new Dictionary>(); + + foreach (var registration in ProviderRegistrations) + { + Register(map, registration.SupportedTypes, registration.Factory); + } + + return map; + } + + private static void Register(Dictionary> map, IReadOnlyCollection types, Func factory) + { + foreach (var type in types) + { + map[type] = factory; + } + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs new file mode 100644 index 0000000000..6bd78450e8 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderRegistration + { + public PasteAIProviderRegistration(IReadOnlyCollection supportedTypes, Func factory) + { + SupportedTypes = supportedTypes ?? throw new ArgumentNullException(nameof(supportedTypes)); + Factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public IReadOnlyCollection SupportedTypes { get; } + + public Func Factory { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs new file mode 100644 index 0000000000..0e15c93e05 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs @@ -0,0 +1,19 @@ +// 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 AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIRequest + { + public string Prompt { get; init; } + + public string InputText { get; init; } + + public string SystemPrompt { get; init; } + + public AIServiceUsage Usage { get; set; } = AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs new file mode 100644 index 0000000000..819549b466 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureAIInference; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.MistralAI; +using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class SemanticKernelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.OpenAI, + AIServiceType.AzureOpenAI, + AIServiceType.Mistral, + AIServiceType.Google, + AIServiceType.AzureAIInference, + AIServiceType.Ollama, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new SemanticKernelPasteProvider(config)); + + private readonly PasteAIConfig _config; + private readonly AIServiceType _serviceType; + + public SemanticKernelPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + _serviceType = config.ProviderType; + if (_serviceType == AIServiceType.Unknown) + { + _serviceType = AIServiceType.OpenAI; + _config.ProviderType = _serviceType; + } + } + + public IReadOnlyCollection SupportedServiceTypes => SupportedTypes; + + public Task IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public async Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new ArgumentException("System prompt must be provided", nameof(request)); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new ArgumentException("Prompt and input text must be provided", nameof(request)); + } + + var userMessageContent = $""" + User instructions: + {prompt} + + Clipboard Content: + {inputText} + + Output: + """; + + var executionSettings = CreateExecutionSettings(); + var kernel = CreateKernel(); + var modelId = _config.Model; + + IChatCompletionService chatService; + if (!string.IsNullOrWhiteSpace(modelId)) + { + try + { + chatService = kernel.GetRequiredService(modelId); + } + catch (Exception) + { + chatService = kernel.GetRequiredService(); + } + } + else + { + chatService = kernel.GetRequiredService(); + } + + var chatHistory = new ChatHistory(); + chatHistory.AddSystemMessage(systemPrompt); + chatHistory.AddUserMessage(userMessageContent); + + var response = await chatService.GetChatMessageContentAsync(chatHistory, executionSettings, kernel, cancellationToken); + chatHistory.Add(response); + + request.Usage = AIServiceUsageHelper.GetOpenAIServiceUsage(response); + return response.Content; + } + + private Kernel CreateKernel() + { + var kernelBuilder = Kernel.CreateBuilder(); + var endpoint = string.IsNullOrWhiteSpace(_config.Endpoint) ? null : _config.Endpoint.Trim(); + var apiKey = _config.ApiKey?.Trim() ?? string.Empty; + + if (RequiresApiKey(_serviceType) && string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"API key is required for {_serviceType} but was not provided."); + } + + switch (_serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(_config.Model, apiKey, serviceId: _config.Model); + break; + case AIServiceType.AzureOpenAI: + var deploymentName = string.IsNullOrWhiteSpace(_config.DeploymentName) ? _config.Model : _config.DeploymentName; + kernelBuilder.AddAzureOpenAIChatCompletion(deploymentName, RequireEndpoint(endpoint, _serviceType), apiKey, serviceId: _config.Model); + break; + case AIServiceType.Mistral: + kernelBuilder.AddMistralChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.Google: + kernelBuilder.AddGoogleAIGeminiChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.AzureAIInference: + kernelBuilder.AddAzureAIInferenceChatCompletion(_config.Model, apiKey: apiKey, endpoint: new Uri(endpoint)); + break; + case AIServiceType.Ollama: + kernelBuilder.AddOllamaChatCompletion(_config.Model, endpoint: new Uri(endpoint)); + break; + + default: + throw new NotSupportedException($"Provider '{_config.ProviderType}' is not supported by {nameof(SemanticKernelPasteProvider)}"); + } + + return kernelBuilder.Build(); + } + + private PromptExecutionSettings CreateExecutionSettings() + { + return _serviceType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings + { + Temperature = 0.01, + MaxTokens = 2000, + FunctionChoiceBehavior = null, + }, + _ => new PromptExecutionSettings(), + }; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Ollama => false, + _ => true, + }; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} but was not provided."); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs new file mode 100644 index 0000000000..648881fba0 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs @@ -0,0 +1,182 @@ +// 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.Linq; +using System.Threading; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Windows.Security.Credentials; + +namespace AdvancedPaste.Services; + +/// +/// Enhanced credentials provider that supports different AI service types +/// Keys are stored in Windows Credential Vault with service-specific identifiers +/// +public sealed class EnhancedVaultCredentialsProvider : IAICredentialsProvider +{ + private sealed class CredentialSlot + { + public AIServiceType ServiceType { get; set; } = AIServiceType.Unknown; + + public string ProviderId { get; set; } = string.Empty; + + public (string Resource, string Username)? Entry { get; set; } + + public string Key { get; set; } = string.Empty; + } + + private readonly IUserSettings _userSettings; + private readonly CredentialSlot _slot; + private readonly Lock _syncRoot = new(); + + public EnhancedVaultCredentialsProvider(IUserSettings userSettings) + { + _userSettings = userSettings ?? throw new ArgumentNullException(nameof(userSettings)); + + _slot = new CredentialSlot(); + + Refresh(); + } + + public string GetKey() + { + using (_syncRoot.EnterScope()) + { + UpdateSlot(forceRefresh: false); + return _slot.Key; + } + } + + public bool IsConfigured() + { + return !string.IsNullOrEmpty(GetKey()); + } + + public bool Refresh() + { + using (_syncRoot.EnterScope()) + { + return UpdateSlot(forceRefresh: true); + } + } + + private bool UpdateSlot(bool forceRefresh) + { + var (serviceType, providerId) = ResolveCredentialTarget(); + var desiredServiceType = NormalizeServiceType(serviceType); + providerId ??= string.Empty; + + var hasChanged = false; + + if (_slot.ServiceType != desiredServiceType || !string.Equals(_slot.ProviderId, providerId, StringComparison.Ordinal)) + { + _slot.ServiceType = desiredServiceType; + _slot.ProviderId = providerId; + _slot.Entry = BuildCredentialEntry(desiredServiceType, providerId); + forceRefresh = true; + hasChanged = true; + } + + if (!forceRefresh) + { + return hasChanged; + } + + var newKey = LoadKey(_slot.Entry); + if (!string.Equals(_slot.Key, newKey, StringComparison.Ordinal)) + { + _slot.Key = newKey; + hasChanged = true; + } + + return hasChanged; + } + + private (AIServiceType ServiceType, string ProviderId) ResolveCredentialTarget() + { + var provider = _userSettings.PasteAIConfiguration?.ActiveProvider; + if (provider is null) + { + return (AIServiceType.OpenAI, string.Empty); + } + + return (provider.ServiceTypeKind, provider.Id ?? string.Empty); + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static string LoadKey((string Resource, string Username)? entry) + { + if (entry is null) + { + return string.Empty; + } + + try + { + var credential = new PasswordVault().Retrieve(entry.Value.Resource, entry.Value.Username); + return credential?.Password ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + + private static (string Resource, string Username)? BuildCredentialEntry(AIServiceType serviceType, string providerId) + { + string resource; + string serviceKey; + + switch (serviceType) + { + case AIServiceType.OpenAI: + resource = "https://platform.openai.com/api-keys"; + serviceKey = "openai"; + break; + case AIServiceType.AzureOpenAI: + resource = "https://azure.microsoft.com/products/ai-services/openai-service"; + serviceKey = "azureopenai"; + break; + case AIServiceType.AzureAIInference: + resource = "https://azure.microsoft.com/products/ai-services/ai-inference"; + serviceKey = "azureaiinference"; + break; + case AIServiceType.Mistral: + resource = "https://console.mistral.ai/account/api-keys"; + serviceKey = "mistral"; + break; + case AIServiceType.Google: + resource = "https://ai.google.dev/"; + serviceKey = "google"; + break; + case AIServiceType.FoundryLocal: + case AIServiceType.ML: + case AIServiceType.Onnx: + case AIServiceType.Ollama: + return null; + default: + return null; + } + + string username = $"PowerToys_AdvancedPaste_PasteAI_{serviceKey}_{NormalizeProviderIdentifier(providerId)}"; + return (resource, username); + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs index 54759b7dc8..7aa6f63b19 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs @@ -4,11 +4,26 @@ namespace AdvancedPaste.Services; +/// +/// Provides access to AI credentials stored for Advanced Paste scenarios. +/// public interface IAICredentialsProvider { - bool IsConfigured { get; } + /// + /// Gets a value indicating whether any credential is configured. + /// + /// when a non-empty credential exists for the active AI provider. + bool IsConfigured(); - string Key { get; } + /// + /// Retrieves the credential for the active AI provider. + /// + /// Credential string or when missing. + string GetKey(); + /// + /// Refreshes the cached credential for the active AI provider. + /// + /// when the credential changed. bool Refresh(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs deleted file mode 100644 index 75f1df259e..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace AdvancedPaste.Services; - -public interface ICustomTextTransformService -{ - Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs new file mode 100644 index 0000000000..d634c13e30 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services; + +/// +/// Represents runtime information required to configure an AI kernel service. +/// +public interface IKernelRuntimeConfiguration +{ + AIServiceType ServiceType { get; } + + string ModelName { get; } + + string Endpoint { get; } + + string DeploymentName { get; } + + string ModelPath { get; } + + string SystemPrompt { get; } + + bool ModerationEnabled { get; } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs index e921b21e54..47e208eb49 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs @@ -5,15 +5,16 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Models.KernelQueryCache; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; using AdvancedPaste.Telemetry; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; @@ -21,15 +22,21 @@ using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheService, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : IKernelService +public abstract class KernelServiceBase( + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) : IKernelService { private const string PromptParameterName = "prompt"; + private const string DefaultSystemPrompt = "You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. Call function when necessary to help user finish the transformation task. You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. The user will put in a request to format their clipboard data and you will fulfill it. Do not output anything else besides the reformatted clipboard content."; private readonly IKernelQueryCacheService _queryCacheService = queryCacheService; private readonly IPromptModerationService _promptModerationService = promptModerationService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly IUserSettings _userSettings = userSettings; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; - protected abstract string ModelName { get; } + protected abstract string AdvancedAIModelName { get; } protected abstract PromptExecutionSettings PromptExecutionSettings { get; } @@ -37,6 +44,8 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi protected abstract AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage); + protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration(); + public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress) { Logger.LogTrace(); @@ -132,21 +141,21 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt, CancellationToken cancellationToken) { + var runtimeConfig = GetRuntimeConfiguration(); + ChatHistory chatHistory = []; - chatHistory.AddSystemMessage(""" - You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. - You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. - The user will put in a request to format their clipboard data and you will fulfill it. - You will not directly see the output clipboard content, and do not need to provide it in the chat. You just need to do the transform operations as needed. - If you are unable to fulfill the request, end with an error message in the language of the user's request. - """); + var systemPrompt = string.IsNullOrWhiteSpace(runtimeConfig.SystemPrompt) ? DefaultSystemPrompt : runtimeConfig.SystemPrompt; + chatHistory.AddSystemMessage(systemPrompt); chatHistory.AddSystemMessage($"Available clipboard formats: {await kernel.GetDataFormatsAsync()}"); chatHistory.AddUserMessage(prompt); - await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + if (ShouldModerateAdvancedAI()) + { + await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + } - var chatResult = await kernel.GetRequiredService() + var chatResult = await kernel.GetRequiredService(AdvancedAIModelName) .GetChatMessageContentAsync(chatHistory, PromptExecutionSettings, kernel, cancellationToken); chatHistory.Add(chatResult); @@ -175,10 +184,26 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return ([], AIServiceUsage.None); } + protected IUserSettings UserSettings => _userSettings; + private void LogResult(bool cacheUsed, bool isSavedQuery, IEnumerable actionChain, AIServiceUsage usage) { - AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new(cacheUsed, isSavedQuery, usage.PromptTokens, usage.CompletionTokens, ModelName, AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); + var runtimeConfig = GetRuntimeConfiguration(); + + AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new( + cacheUsed, + isSavedQuery, + usage.PromptTokens, + usage.CompletionTokens, + AdvancedAIModelName, + runtimeConfig.ServiceType.ToString(), + AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); PowerToysTelemetry.Log.WriteEvent(telemetryEvent); + + // Log endpoint usage + var endpointEvent = new AdvancedPasteEndpointUsageEvent(runtimeConfig.ServiceType, AdvancedAIModelName, isAdvanced: true); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + var logEvent = new AIServiceFormatEvent(telemetryEvent); Logger.LogDebug($"{nameof(TransformClipboardAsync)} complete; {logEvent.ToJsonString()}"); } @@ -191,20 +216,96 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return kernelBuilder.Build(); } - private IEnumerable GetKernelFunctions() => - from format in Enum.GetValues() - let metadata = PasteFormat.MetadataDict[format] - let coreDescription = metadata.KernelFunctionDescription - where !string.IsNullOrEmpty(coreDescription) - let requiresPrompt = metadata.RequiresPrompt - orderby requiresPrompt descending - select KernelFunctionFactory.CreateFromMethod( - method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) - : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), - functionName: format.ToString(), - description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", - parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, - returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + private IEnumerable GetKernelFunctions() + { + // Get standard format functions + var standardFunctions = + from format in Enum.GetValues() + let metadata = PasteFormat.MetadataDict[format] + let coreDescription = metadata.KernelFunctionDescription + where !string.IsNullOrEmpty(coreDescription) + let requiresPrompt = metadata.RequiresPrompt + orderby requiresPrompt descending + select KernelFunctionFactory.CreateFromMethod( + method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) + : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), + functionName: format.ToString(), + description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", + parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + + HashSet usedFunctionNames = new(Enum.GetNames(), StringComparer.OrdinalIgnoreCase); + + // Get custom action functions + var customActionFunctions = _userSettings.CustomActions + .Where(customAction => !string.IsNullOrWhiteSpace(customAction.Name) && !string.IsNullOrWhiteSpace(customAction.Prompt)) + .Select(customAction => + { + var sanitizedBaseName = SanitizeFunctionName(customAction.Name); + var functionName = GetUniqueFunctionName(sanitizedBaseName, usedFunctionNames, customAction.Id); + var description = string.IsNullOrWhiteSpace(customAction.Description) + ? $"Runs the \"{customAction.Name}\" custom action." + : customAction.Description; + return KernelFunctionFactory.CreateFromMethod( + method: async (Kernel kernel) => await ExecuteCustomActionAsync(kernel, customAction.Prompt), + functionName: functionName, + description: description, + parameters: null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + }); + + return standardFunctions.Concat(customActionFunctions); + } + + private static string GetUniqueFunctionName(string baseName, HashSet usedFunctionNames, int customActionId) + { + ArgumentNullException.ThrowIfNull(usedFunctionNames); + + var candidate = string.IsNullOrEmpty(baseName) ? "_CustomAction" : baseName; + + if (usedFunctionNames.Add(candidate)) + { + return candidate; + } + + int suffix = 1; + while (true) + { + var nextCandidate = $"{candidate}_{customActionId}_{suffix}"; + if (usedFunctionNames.Add(nextCandidate)) + { + return nextCandidate; + } + + suffix++; + } + } + + private static string SanitizeFunctionName(string name) + { + // Remove invalid characters and ensure the function name is valid for kernel + var sanitized = new string(name.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray()); + + // Ensure it starts with a letter or underscore + if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]) && sanitized[0] != '_') + { + sanitized = "_" + sanitized; + } + + // Ensure it's not empty + return string.IsNullOrEmpty(sanitized) ? "_CustomAction" : sanitized; + } + + private Task ExecuteCustomActionAsync(Kernel kernel, string fixedPrompt) => + ExecuteTransformAsync( + kernel, + new ActionChainItem(PasteFormats.CustomTextTransformation, Arguments: new() { { PromptParameterName, fixedPrompt } }), + async dataPackageView => + { + var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); + var result = await _customActionTransformService.TransformTextAsync(fixedPrompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); + return DataPackageHelpers.CreateFromText(result?.Content ?? string.Empty); + }); private Task ExecutePromptTransformAsync(Kernel kernel, PasteFormats format, string prompt) => ExecuteTransformAsync( @@ -212,7 +313,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi new ActionChainItem(format, Arguments: new() { { PromptParameterName, prompt } }), async dataPackageView => { - var input = await dataPackageView.GetTextAsync(); + var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); string output = await GetPromptBasedOutput(format, prompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); return DataPackageHelpers.CreateFromText(output); }); @@ -220,7 +321,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task GetPromptBasedOutput(PasteFormats format, string prompt, string input, CancellationToken cancellationToken, IProgress progress) => format switch { - PasteFormats.CustomTextTransformation => await _customTextTransformService.TransformTextAsync(prompt, input, cancellationToken, progress), + PasteFormats.CustomTextTransformation => (await _customActionTransformService.TransformTextAsync(prompt, input, cancellationToken, progress))?.Content ?? string.Empty, _ => throw new ArgumentException($"Unsupported format {format} for prompt transform", nameof(format)), }; @@ -281,4 +382,9 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi var usageString = usage.HasUsage ? $" [{usage}]" : string.Empty; return $"-> {role}: {redactedContent}{usageString}"; } + + protected virtual bool ShouldModerateAdvancedAI() + { + return false; + } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs deleted file mode 100644 index b6aa156b9d..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -using AdvancedPaste.Helpers; -using AdvancedPaste.Models; -using AdvancedPaste.Telemetry; -using Azure; -using Azure.AI.OpenAI; -using ManagedCommon; -using Microsoft.PowerToys.Telemetry; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class CustomTextTransformService(IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService) : ICustomTextTransformService -{ - private const string ModelName = "gpt-3.5-turbo-instruct"; - - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - private readonly IPromptModerationService _promptModerationService = promptModerationService; - - private async Task GetAICompletionAsync(string systemInstructions, string userMessage, CancellationToken cancellationToken) - { - var fullPrompt = systemInstructions + "\n\n" + userMessage; - - await _promptModerationService.ValidateAsync(fullPrompt, cancellationToken); - - OpenAIClient azureAIClient = new(_aiCredentialsProvider.Key); - - var response = await azureAIClient.GetCompletionsAsync( - new() - { - DeploymentName = ModelName, - Prompts = - { - fullPrompt, - }, - Temperature = 0.01F, - MaxTokens = 2000, - }, - cancellationToken); - - if (response.Value.Choices[0].FinishReason == "length") - { - Logger.LogDebug("Cut off due to length constraints"); - } - - return response; - } - - public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) - { - if (string.IsNullOrWhiteSpace(prompt)) - { - return string.Empty; - } - - if (string.IsNullOrWhiteSpace(inputText)) - { - Logger.LogWarning("Clipboard has no usable text data"); - return string.Empty; - } - - string systemInstructions = -$@"You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. -Do not output anything else besides the reformatted clipboard content."; - - string userMessage = -$@"User instructions: -{prompt} - -Clipboard Content: -{inputText} - -Output: -"; - - try - { - var response = await GetAICompletionAsync(systemInstructions, userMessage, cancellationToken); - - var usage = response.Usage; - AdvancedPasteGenerateCustomFormatEvent telemetryEvent = new(usage.PromptTokens, usage.CompletionTokens, ModelName); - PowerToysTelemetry.Log.WriteEvent(telemetryEvent); - var logEvent = new AIServiceFormatEvent(telemetryEvent); - - Logger.LogDebug($"{nameof(TransformTextAsync)} complete; {logEvent.ToJsonString()}"); - - return response.Choices[0].Text; - } - catch (Exception ex) - { - Logger.LogError($"{nameof(TransformTextAsync)} failed", ex); - - AdvancedPasteGenerateCustomErrorEvent errorEvent = new(ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); - PowerToysTelemetry.Log.WriteEvent(errorEvent); - - if (ex is PasteActionException or OperationCanceledException) - { - throw; - } - else - { - throw new PasteActionException(ErrorHelpers.TranslateErrorText((ex as RequestFailedException)?.Status ?? -1), ex); - } - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs deleted file mode 100644 index b19a6d51cb..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Generic; - -using AdvancedPaste.Models; -using Azure.AI.OpenAI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.OpenAI; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class KernelService(IKernelQueryCacheService queryCacheService, IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : - KernelServiceBase(queryCacheService, promptModerationService, customTextTransformService) -{ - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - - protected override string ModelName => "gpt-4o"; - - protected override PromptExecutionSettings PromptExecutionSettings => - new OpenAIPromptExecutionSettings() - { - ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions, - Temperature = 0.01, - }; - - protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) => kernelBuilder.AddOpenAIChatCompletion(ModelName, _aiCredentialsProvider.Key); - - protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) => - chatMessage.Metadata?.GetValueOrDefault("Usage") is CompletionsUsage completionsUsage - ? new(PromptTokens: completionsUsage.PromptTokens, CompletionTokens: completionsUsage.CompletionTokens) - : AIServiceUsage.None; -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs index 0ca15e4161..2668300526 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; using ManagedCommon; using OpenAI.Moderations; @@ -23,7 +24,16 @@ public sealed class PromptModerationService(IAICredentialsProvider aiCredentials { try { - ModerationClient moderationClient = new(ModelName, _aiCredentialsProvider.Key); + _aiCredentialsProvider.Refresh(); + var apiKey = _aiCredentialsProvider.GetKey()?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(apiKey)) + { + Logger.LogWarning("Skipping OpenAI moderation because no credential is configured."); + return; + } + + ModerationClient moderationClient = new(ModelName, apiKey); var moderationClientResult = await moderationClient.ClassifyTextAsync(fullPrompt, cancellationToken); var moderationResult = moderationClientResult.Value; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs deleted file mode 100644 index 169c1c2422..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; - -using Windows.Security.Credentials; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class VaultCredentialsProvider : IAICredentialsProvider -{ - public VaultCredentialsProvider() => Refresh(); - - public string Key { get; private set; } - - public bool IsConfigured => !string.IsNullOrEmpty(Key); - - public bool Refresh() - { - var oldKey = Key; - Key = LoadKey(); - return oldKey != Key; - } - - private static string LoadKey() - { - try - { - return new PasswordVault().Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey")?.Password ?? string.Empty; - } - catch (Exception) - { - return string.Empty; - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs index 5d6740977b..aef9e39bb9 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -8,15 +8,16 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; using Microsoft.PowerToys.Telemetry; using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTextTransformService customTextTransformService) : IPasteFormatExecutor +public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor { private readonly IKernelService _kernelService = kernelService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress progress) { @@ -36,7 +37,7 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTex pasteFormat.Format switch { PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress), - PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText(await _customTextTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetTextAsync(), cancellationToken, progress)), + PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetClipboardTextOrThrowAsync(cancellationToken), cancellationToken, progress))?.Content ?? string.Empty), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw index 604cbf403b..f365778321 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw +++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw @@ -144,16 +144,67 @@ The paste operation was moderated due to sensitive content. Please try another query. - + Clipboard history Clipboard history + + AI provider selector + + + Select an AI provider + + + Active provider: {0} + + + Configured models + + + No models configured + + + Configure models in Settings + Image data Label used to represent an image in the clipboard history + + Text + + + Image + + + Audio + + + Video + + + File + + + Clipboard + + + Copied just now + + + Copied {0} sec ago + + + Copied {0} min ago + + + Copied {0} hr ago + + + Copied {0} day ago + More options @@ -196,7 +247,7 @@ Transcode to .mp3 Option to transcode audio files to MP3 format - + Transcode to .mp4 (H.264/AAC) Option to transcode video files to MP4 format with H.264 video codec and AAC audio codec @@ -272,11 +323,11 @@ Next result - - OpenAI Privacy + + Privacy Policy - - OpenAI Terms + + Terms To custom with AI is disabled by your organization @@ -287,4 +338,38 @@ PowerToys_Paste_ + + Just now + + + 1 minute ago + + + {0} minutes ago + + + Today, {0} + + + Yesterday, {0} + + + {0}, {1} + (e.g., “Wednesday, 17:05”) + + + {0}, {1} + (e.g., "10/20/2025, 17:05" in the user's locale) + + + You are using a custom endpoint. Verify all answers. + + + Local + Badge label displayed next to local AI model providers (e.g., Ollama, Foundry Local) to indicate the model runs locally + + + Unable to load Foundry Local model: {0} + {0} is the model identifier. Do not translate {0}. + \ No newline at end of file diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs new file mode 100644 index 0000000000..06f45a98ae --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace AdvancedPaste.Telemetry; + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public sealed class AdvancedPasteCustomActionErrorEvent : EventBase, IEvent +{ + public AdvancedPasteCustomActionErrorEvent(AIServiceType providerType, string modelName, int statusCode, string error) + { + ProviderType = providerType.ToString(); + ModelName = modelName; + StatusCode = statusCode; + Error = error; + } + + public string ProviderType { get; set; } + + public string ModelName { get; set; } + + public int StatusCode { get; set; } + + public string Error { get; set; } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs new file mode 100644 index 0000000000..671f6a7b9c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace AdvancedPaste.Telemetry; + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class AdvancedPasteEndpointUsageEvent : EventBase, IEvent +{ + /// + /// Gets or sets the AI provider type (e.g., OpenAI, AzureOpenAI, Google). + /// + public string ProviderType { get; set; } + + /// + /// Gets or sets the configured model name. + /// + public string ModelName { get; set; } + + /// + /// Gets or sets a value indicating whether the advanced AI pipeline was used. + /// + public bool IsAdvanced { get; set; } + + /// + /// Gets or sets the total duration in milliseconds, or -1 if unavailable. + /// + public int DurationMs { get; set; } + + public AdvancedPasteEndpointUsageEvent(AIServiceType providerType, string modelName, bool isAdvanced, int durationMs = -1) + { + ProviderType = providerType.ToString(); + ModelName = modelName; + IsAdvanced = isAdvanced; + DurationMs = durationMs; + } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs index 70542da6c8..53b4008782 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs @@ -14,7 +14,7 @@ namespace AdvancedPaste.Telemetry; [EventData] [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] -public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSavedQuery, int promptTokens, int completionTokens, string modelName, string actionChain) : EventBase, IEvent +public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSavedQuery, int promptTokens, int completionTokens, string modelName, string providerType, string actionChain) : EventBase, IEvent { public static string FormatActionChain(IEnumerable actionChain) => FormatActionChain(actionChain.Select(item => item.Format)); @@ -30,6 +30,8 @@ public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSaved public string ModelName { get; set; } = modelName; + public string ProviderType { get; set; } = providerType; + /// /// Gets or sets a comma-separated list of paste formats used - in the same order they were executed. /// Conceptually an array but formatted this way to work around https://github.com/dotnet/runtime/issues/10428 diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 688c3047e2..8edd9b76ad 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Globalization; using System.IO.Abstractions; using System.Linq; using System.Runtime.InteropServices; @@ -22,6 +23,8 @@ using CommunityToolkit.Mvvm.Input; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.System; @@ -37,12 +40,20 @@ namespace AdvancedPaste.ViewModels private readonly DispatcherTimer _clipboardTimer; private readonly IUserSettings _userSettings; private readonly IPasteFormatExecutor _pasteFormatExecutor; - private readonly IAICredentialsProvider _aiCredentialsProvider; + private readonly IAICredentialsProvider _credentialsProvider; private CancellationTokenSource _pasteActionCancellationTokenSource; + private string _currentClipboardHistoryId; + private DateTimeOffset? _currentClipboardTimestamp; + private ClipboardFormat _lastClipboardFormats = ClipboardFormat.None; + private bool _clipboardHistoryUnavailableLogged; + public DataPackageView ClipboardData { get; set; } + [ObservableProperty] + private ClipboardItem _currentClipboardItem; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] [NotifyPropertyChangedFor(nameof(ClipboardHasData))] @@ -52,12 +63,21 @@ namespace AdvancedPaste.ViewModels private ClipboardFormat _availableClipboardFormats; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowClipboardHistoryButton))] private bool _clipboardHistoryEnabled; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CustomAIUnavailableErrorText))] [NotifyPropertyChangedFor(nameof(IsCustomAIServiceEnabled))] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] + [NotifyPropertyChangedFor(nameof(AllowedAIProviders))] + [NotifyPropertyChangedFor(nameof(ActiveAIProvider))] + [NotifyPropertyChangedFor(nameof(ActiveAIProviderTooltip))] + [NotifyPropertyChangedFor(nameof(TermsLinkUri))] + [NotifyPropertyChangedFor(nameof(PrivacyLinkUri))] + [NotifyPropertyChangedFor(nameof(HasTermsLink))] + [NotifyPropertyChangedFor(nameof(HasPrivacyLink))] + [NotifyPropertyChangedFor(nameof(HasLegalLinks))] private bool _isAllowedByGPO; [ObservableProperty] @@ -79,19 +99,146 @@ namespace AdvancedPaste.ViewModels public ObservableCollection CustomActionPasteFormats { get; } = []; - public bool IsCustomAIServiceEnabled => IsAllowedByGPO && _aiCredentialsProvider.IsConfigured; + public bool IsCustomAIServiceEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + // Check if there are any allowed providers + if (!AllowedAIProviders.Any()) + { + return false; + } + + // We should handle the IsAIEnabled logic in settings, don't check again here. + // If setting says yes, and here should pass check, and if error happens, it happens. + return true; + } + } public bool IsCustomAIAvailable => IsCustomAIServiceEnabled && ClipboardHasDataForCustomAI; - public bool IsAdvancedAIEnabled => IsCustomAIServiceEnabled && _userSettings.IsAdvancedAIEnabled; + public bool IsAdvancedAIEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + if (!TryResolveAdvancedAIProvider(out _)) + { + return false; + } + + return _credentialsProvider.IsConfigured(); + } + } + + public ObservableCollection AIProviders => _userSettings?.PasteAIConfiguration?.Providers ?? new ObservableCollection(); + + public IEnumerable AllowedAIProviders + { + get + { + var providers = AIProviders; + if (providers is null || providers.Count == 0) + { + return Enumerable.Empty(); + } + + return providers.Where(IsProviderAllowedByGPO); + } + } + + public PasteAIProviderDefinition ActiveAIProvider + { + get + { + var provider = _userSettings?.PasteAIConfiguration?.ActiveProvider; + if (provider is null || !IsProviderAllowedByGPO(provider)) + { + return null; + } + + return provider; + } + } + + public string ActiveAIProviderTooltip + { + get + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var provider = ActiveAIProvider; + + if (provider is null) + { + return resourceLoader.GetString("AIProviderButtonTooltipEmpty"); + } + + var format = resourceLoader.GetString("AIProviderButtonTooltipFormat"); + var displayName = provider.DisplayName; + + if (!string.IsNullOrEmpty(format)) + { + return string.Format(CultureInfo.CurrentCulture, format, displayName); + } + + return displayName; + } + } + + private AIServiceTypeMetadata GetActiveProviderMetadata() + { + var provider = ActiveAIProvider ?? AllowedAIProviders.FirstOrDefault(); + var serviceType = provider?.ServiceTypeKind ?? AIServiceType.OpenAI; + return AIServiceTypeRegistry.GetMetadata(serviceType); + } + + public Uri TermsLinkUri + { + get + { + var metadata = GetActiveProviderMetadata(); + return metadata.HasTermsLink ? metadata.TermsUri : null; + } + } + + public Uri PrivacyLinkUri + { + get + { + var metadata = GetActiveProviderMetadata(); + return metadata.HasPrivacyLink ? metadata.PrivacyUri : null; + } + } + + public bool HasTermsLink => GetActiveProviderMetadata().HasTermsLink; + + public bool HasPrivacyLink => GetActiveProviderMetadata().HasPrivacyLink; + + public bool HasLegalLinks => HasTermsLink || HasPrivacyLink; public bool ClipboardHasData => AvailableClipboardFormats != ClipboardFormat.None; public bool ClipboardHasDataForCustomAI => PasteFormat.SupportsClipboardFormats(CustomAIFormat, AvailableClipboardFormats); + public bool ShowClipboardPreview => _userSettings.EnableClipboardPreview; + + public bool ShowClipboardHistoryButton => ClipboardHistoryEnabled; + public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress); - private PasteFormats CustomAIFormat => _userSettings.IsAdvancedAIEnabled ? PasteFormats.KernelQuery : PasteFormats.CustomTextTransformation; + private PasteFormats CustomAIFormat => + _userSettings.IsAIEnabled && TryResolveAdvancedAIProvider(out _) + ? PasteFormats.KernelQuery + : PasteFormats.CustomTextTransformation; private bool Visible { @@ -110,9 +257,9 @@ namespace AdvancedPaste.ViewModels public event EventHandler PreviewRequested; - public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider aiCredentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) + public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) { - _aiCredentialsProvider = aiCredentialsProvider; + _credentialsProvider = credentialsProvider; _userSettings = userSettings; _pasteFormatExecutor = pasteFormatExecutor; @@ -130,6 +277,7 @@ namespace AdvancedPaste.ViewModels _clipboardTimer.Start(); RefreshPasteFormats(); + UpdateAIProviderActiveFlags(); _userSettings.Changed += UserSettings_Changed; PropertyChanged += (_, e) => { @@ -158,15 +306,21 @@ namespace AdvancedPaste.ViewModels if (Visible) { await ReadClipboardAsync(); - UpdateAllowedByGPO(); } } private void UserSettings_Changed(object sender, EventArgs e) { + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); OnPropertyChanged(nameof(ClipboardHasDataForCustomAI)); OnPropertyChanged(nameof(IsCustomAIAvailable)); OnPropertyChanged(nameof(IsAdvancedAIEnabled)); + OnPropertyChanged(nameof(AIProviders)); + OnPropertyChanged(nameof(AllowedAIProviders)); + OnPropertyChanged(nameof(ShowClipboardPreview)); + + NotifyActiveProviderChanged(); EnqueueRefreshPasteFormats(); } @@ -192,6 +346,33 @@ namespace AdvancedPaste.ViewModels private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) => PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled); + private void UpdateAIProviderActiveFlags() + { + var providers = _userSettings?.PasteAIConfiguration?.Providers; + if (providers is not null) + { + var activeId = ActiveAIProvider?.Id; + + foreach (var provider in providers) + { + provider.IsActive = !string.IsNullOrEmpty(activeId) && string.Equals(provider.Id, activeId, StringComparison.OrdinalIgnoreCase); + } + } + + NotifyActiveProviderChanged(); + } + + private void NotifyActiveProviderChanged() + { + OnPropertyChanged(nameof(ActiveAIProvider)); + OnPropertyChanged(nameof(ActiveAIProviderTooltip)); + OnPropertyChanged(nameof(TermsLinkUri)); + OnPropertyChanged(nameof(PrivacyLinkUri)); + OnPropertyChanged(nameof(HasTermsLink)); + OnPropertyChanged(nameof(HasPrivacyLink)); + OnPropertyChanged(nameof(HasLegalLinks)); + } + private void RefreshPasteFormats() { var ctrlString = ResourceLoaderInstance.ResourceLoader.GetString("CtrlKey"); @@ -253,8 +434,96 @@ namespace AdvancedPaste.ViewModels return; } - ClipboardData = Clipboard.GetContent(); - AvailableClipboardFormats = await ClipboardData.GetAvailableFormatsAsync(); + try + { + ClipboardData = Clipboard.GetContent(); + AvailableClipboardFormats = ClipboardData != null ? await ClipboardData.GetAvailableFormatsAsync() : ClipboardFormat.None; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + // Logger.LogDebug("Failed to read clipboard content", ex); + ClipboardData = null; + AvailableClipboardFormats = ClipboardFormat.None; + } + + await UpdateClipboardPreviewAsync(); + } + + private async Task UpdateClipboardPreviewAsync() + { + if (ClipboardData is null || !ClipboardHasData) + { + ResetClipboardPreview(); + _currentClipboardHistoryId = null; + _currentClipboardTimestamp = null; + _lastClipboardFormats = ClipboardFormat.None; + return; + } + + var formatsChanged = AvailableClipboardFormats != _lastClipboardFormats; + _lastClipboardFormats = AvailableClipboardFormats; + + var clipboardChanged = await UpdateClipboardTimestampAsync(formatsChanged); + + // Create ClipboardItem directly from current clipboard data using helper + CurrentClipboardItem = await ClipboardItemHelper.CreateFromCurrentClipboardAsync( + ClipboardData, + AvailableClipboardFormats, + _currentClipboardTimestamp, + clipboardChanged ? null : CurrentClipboardItem?.Image); + } + + private async Task UpdateClipboardTimestampAsync(bool formatsChanged) + { + bool clipboardChanged = formatsChanged; + + if (Clipboard.IsHistoryEnabled()) + { + try + { + var historyItems = await Clipboard.GetHistoryItemsAsync(); + if (historyItems.Status == ClipboardHistoryItemsResultStatus.Success && historyItems.Items.Count > 0) + { + var latest = historyItems.Items[0]; + if (_currentClipboardHistoryId != latest.Id) + { + clipboardChanged = true; + _currentClipboardHistoryId = latest.Id; + } + + _currentClipboardTimestamp = latest.Timestamp; + _clipboardHistoryUnavailableLogged = false; + return clipboardChanged; + } + } + catch (Exception ex) + { + if (!_clipboardHistoryUnavailableLogged) + { + Logger.LogDebug("Failed to access clipboard history timestamp", ex.Message); + _clipboardHistoryUnavailableLogged = true; + } + } + } + + if (!_currentClipboardTimestamp.HasValue || clipboardChanged) + { + _currentClipboardTimestamp = DateTimeOffset.Now; + clipboardChanged = true; + } + + return clipboardChanged; + } + + private void ResetClipboardPreview() + { + // Clear to avoid leaks due to Garbage Collection not clearing the bitmap from memory + if (CurrentClipboardItem?.Image is not null) + { + CurrentClipboardItem.Image.ClearValue(BitmapImage.UriSourceProperty); + } + + CurrentClipboardItem = null; } public async Task OnShowAsync() @@ -270,7 +539,7 @@ namespace AdvancedPaste.ViewModels _dispatcherQueue.TryEnqueue(() => { - GetMainWindow()?.FinishLoading(_aiCredentialsProvider.IsConfigured); + GetMainWindow()?.FinishLoading(IsCustomAIServiceEnabled); OnPropertyChanged(nameof(InputTxtBoxPlaceholderText)); OnPropertyChanged(nameof(CustomAIUnavailableErrorText)); OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); @@ -319,7 +588,7 @@ namespace AdvancedPaste.ViewModels return ResourceLoaderInstance.ResourceLoader.GetString("OpenAIGpoDisabled"); } - if (!_aiCredentialsProvider.IsConfigured) + if (!IsCustomAIServiceEnabled) { return ResourceLoaderInstance.ResourceLoader.GetString("OpenAINotConfigured"); } @@ -515,11 +784,113 @@ namespace AdvancedPaste.ViewModels IsAllowedByGPO = PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOnlineAIModelsValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled; } + private bool IsProviderAllowedByGPO(PasteAIProviderDefinition provider) + { + if (provider is null) + { + return false; + } + + var serviceType = provider.ServiceType.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + + // Check global online AI GPO for online services + if (metadata.IsOnlineService && !IsAllowedByGPO) + { + return false; + } + + // Check individual endpoint GPO + return serviceType switch + { + AIServiceType.OpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureOpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureAIInference => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureAIInferenceValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Mistral => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteMistralValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Google => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteGoogleValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Ollama => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOllamaValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.FoundryLocal => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteFoundryLocalValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + _ => true, // Allow unknown types by default + }; + } + + private bool TryResolveAdvancedAIProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = _userSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedAIProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedAIProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedAIProvider(PasteAIProviderDefinition provider) + { + return provider is not null && provider.EnableAdvancedAI && SupportsAdvancedAI(provider.ServiceTypeKind); + } + + private static bool SupportsAdvancedAI(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI + or AIServiceType.AzureOpenAI; + } + private bool UpdateOpenAIKey() { UpdateAllowedByGPO(); - return IsAllowedByGPO && _aiCredentialsProvider.Refresh(); + return _credentialsProvider.Refresh(); + } + + [RelayCommand] + private async Task SetActiveProviderAsync(PasteAIProviderDefinition provider) + { + if (provider is null || string.IsNullOrEmpty(provider.Id)) + { + return; + } + + if (string.Equals(ActiveAIProvider?.Id, provider.Id, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + await _userSettings.SetActiveAIProviderAsync(provider.Id); + } + catch (Exception ex) + { + Logger.LogError("Failed to activate AI provider", ex); + return; + } + + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(AIProviders)); + NotifyActiveProviderChanged(); + EnqueueRefreshPasteFormats(); } public async Task CancelPasteActionAsync() diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj index 083aa868d3..2cf2920673 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj @@ -2,7 +2,7 @@ - + 15.0 diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 6af0d636ac..c7d22d474f 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -16,7 +16,8 @@ #include #include -#include +#include +#include #include BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) @@ -54,12 +55,14 @@ namespace const wchar_t JSON_KEY_ADVANCED_PASTE_UI_HOTKEY[] = L"advanced-paste-ui-hotkey"; const wchar_t JSON_KEY_PASTE_AS_MARKDOWN_HOTKEY[] = L"paste-as-markdown-hotkey"; const wchar_t JSON_KEY_PASTE_AS_JSON_HOTKEY[] = L"paste-as-json-hotkey"; - const wchar_t JSON_KEY_IS_ADVANCED_AI_ENABLED[] = L"IsAdvancedAIEnabled"; + const wchar_t JSON_KEY_IS_AI_ENABLED[] = L"IsAIEnabled"; + const wchar_t JSON_KEY_IS_OPEN_AI_ENABLED[] = L"IsOpenAIEnabled"; const wchar_t JSON_KEY_SHOW_CUSTOM_PREVIEW[] = L"ShowCustomPreview"; + const wchar_t JSON_KEY_PASTE_AI_CONFIGURATION[] = L"paste-ai-configuration"; + const wchar_t JSON_KEY_PROVIDERS[] = L"providers"; + const wchar_t JSON_KEY_SERVICE_TYPE[] = L"service-type"; + const wchar_t JSON_KEY_ENABLE_ADVANCED_AI[] = L"enable-advanced-ai"; const wchar_t JSON_KEY_VALUE[] = L"value"; - - const wchar_t OPENAI_VAULT_RESOURCE[] = L"https://platform.openai.com/api-keys"; - const wchar_t OPENAI_VAULT_USERNAME[] = L"PowerToys_AdvancedPaste_OpenAIKey"; } class AdvancedPaste : public PowertoyModuleIface @@ -94,6 +97,7 @@ private: using CustomAction = ActionData; std::vector m_custom_actions; + bool m_is_ai_enabled = false; bool m_is_advanced_ai_enabled = false; bool m_preview_custom_format_output = true; @@ -145,32 +149,11 @@ private: return jsonObject; } - static bool open_ai_key_exists() - { - try - { - winrt::Windows::Security::Credentials::PasswordVault().Retrieve(OPENAI_VAULT_RESOURCE, OPENAI_VAULT_USERNAME); - return true; - } - catch (const winrt::hresult_error& ex) - { - // Looks like the only way to access the PasswordVault is through an API that throws an exception in case the resource doesn't exist. - // If the debugger breaks here, just continue. - // If you want to disable breaking here in a more permanent way, just add a condition in Visual Studio's Exception Settings to not break on win::hresult_error, but that might make you not hit other exceptions you might want to catch. - if (ex.code() == HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) - { - return false; // Credential doesn't exist. - } - Logger::error("Unexpected error while retrieving OpenAI key from vault: {}", winrt::to_string(ex.message())); - return false; - } - } - - bool is_open_ai_enabled() + bool is_ai_enabled() { return gpo_policy_enabled_configuration() != powertoys_gpo::gpo_rule_configured_disabled && powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue() != powertoys_gpo::gpo_rule_configured_disabled && - open_ai_key_exists(); + m_is_ai_enabled; } static std::wstring kebab_to_pascal_case(const std::wstring& kebab_str) @@ -201,6 +184,13 @@ private: return result; } + static std::wstring to_lower_case(const std::wstring& value) + { + std::wstring result = value; + std::transform(result.begin(), result.end(), result.begin(), [](wchar_t ch) { return std::towlower(ch); }); + return result; + } + bool migrate_data_and_remove_data_file(Hotkey& old_paste_as_plain_hotkey) { const wchar_t OLD_JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut"; @@ -267,6 +257,61 @@ private: } } + bool has_advanced_ai_provider(const winrt::Windows::Data::Json::JsonObject& propertiesObject) + { + if (!propertiesObject.HasKey(JSON_KEY_PASTE_AI_CONFIGURATION)) + { + return false; + } + + const auto configValue = propertiesObject.GetNamedValue(JSON_KEY_PASTE_AI_CONFIGURATION); + if (configValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + return false; + } + + const auto configObject = configValue.GetObjectW(); + if (!configObject.HasKey(JSON_KEY_PROVIDERS)) + { + return false; + } + + const auto providersValue = configObject.GetNamedValue(JSON_KEY_PROVIDERS); + if (providersValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Array) + { + return false; + } + + const auto providers = providersValue.GetArray(); + for (const auto providerValue : providers) + { + if (providerValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + continue; + } + + const auto providerObject = providerValue.GetObjectW(); + if (!providerObject.GetNamedBoolean(JSON_KEY_ENABLE_ADVANCED_AI, false)) + { + continue; + } + + if (!providerObject.HasKey(JSON_KEY_SERVICE_TYPE)) + { + continue; + } + + const std::wstring serviceType = providerObject.GetNamedString(JSON_KEY_SERVICE_TYPE, L"").c_str(); + const auto normalizedServiceType = to_lower_case(serviceType); + if (normalizedServiceType == L"openai" || normalizedServiceType == L"azureopenai") + { + return true; + } + } + + return false; + } + void read_settings(PowerToysSettings::PowerToyValues& settings) { const auto settingsObject = settings.get_raw_json(); @@ -341,7 +386,7 @@ private: if (propertiesObject.HasKey(JSON_KEY_CUSTOM_ACTIONS)) { const auto customActions = propertiesObject.GetNamedObject(JSON_KEY_CUSTOM_ACTIONS).GetNamedArray(JSON_KEY_VALUE); - if (customActions.Size() > 0 && is_open_ai_enabled()) + if (customActions.Size() > 0 && is_ai_enabled()) { for (const auto& customAction : customActions) { @@ -365,9 +410,19 @@ private: { const auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); - if (propertiesObject.HasKey(JSON_KEY_IS_ADVANCED_AI_ENABLED)) + m_is_advanced_ai_enabled = has_advanced_ai_provider(propertiesObject); + + if (propertiesObject.HasKey(JSON_KEY_IS_AI_ENABLED)) { - m_is_advanced_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_ADVANCED_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE); + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else if (propertiesObject.HasKey(JSON_KEY_IS_OPEN_AI_ENABLED)) + { + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_OPEN_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else + { + m_is_ai_enabled = false; } if (propertiesObject.HasKey(JSON_KEY_SHOW_CUSTOM_PREVIEW)) diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json index 31ad05c701..bc0803796e 100644 --- a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json @@ -1 +1 @@ -{"properties":{"IsAdvancedAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file +{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file diff --git a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml index c48b7fbb25..ae77a78caa 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml +++ b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml @@ -20,7 +20,7 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 12 diff --git a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml index 7292173836..01403ba36e 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml +++ b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml @@ -20,7 +20,7 @@ - + _userSettings; private static Mock _elevationHelper; + private static Mock _backupManager; // Case1: Fuzzing method for ValidIPv4 public static void FuzzValidIPv4(ReadOnlySpan input) @@ -73,9 +70,10 @@ namespace Hosts.FuzzTests _userSettings = new Mock(); _elevationHelper = new Mock(); _elevationHelper.Setup(m => m.IsElevated).Returns(true); + _backupManager = new Mock(); var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); string input = System.Text.Encoding.UTF8.GetString(data); diff --git a/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj b/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj index 667cfcc0ad..51dee7a40b 100644 --- a/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj +++ b/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj @@ -30,8 +30,11 @@ + + + diff --git a/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs b/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs new file mode 100644 index 0000000000..6aeb834029 --- /dev/null +++ b/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO.Abstractions.TestingHelpers; +using HostsUILib.Helpers; +using HostsUILib.Settings; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Hosts.Tests +{ + [TestClass] + public class BackupManagerTest + { + private const string HostsPath = @"C:\Windows\System32\Drivers\etc\hosts"; + private const string BackupPath = @"C:\Backup\hosts"; + private const string BackupSearchPattern = $"*_PowerToysBackup_*"; + + [TestMethod] + public void Hosts_Backup_Not_Executed() + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, true); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupHosts).Returns(false); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Create(HostsPath); + + Assert.AreEqual(0, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + } + + [TestMethod] + public void Hosts_Backup_Executed_Once() + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, true); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupHosts).Returns(true); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Create(HostsPath); + backupManager.Create(HostsPath); + + Assert.AreEqual(1, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + var hostsContent = fileSystem.File.ReadAllText(HostsPath); + var backupContent = fileSystem.File.ReadAllText(fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern)[0]); + Assert.AreEqual(hostsContent, backupContent); + } + + [DataTestMethod] + [DataRow(-10, -10)] + [DataRow(-10, 0)] + [DataRow(-10, 10)] + [DataRow(0, -10)] + [DataRow(0, 0)] + [DataRow(0, 10)] + [DataRow(10, -10)] + [DataRow(10, 0)] + [DataRow(10, 10)] + public void Hosts_Backups_Delete_Never(int count, int days) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Never); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(30, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(31, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [DataTestMethod] + [DataRow(-10, 30)] + [DataRow(0, 30)] + [DataRow(10, 10)] + public void Hosts_Backups_Delete_ByCount(int count, int expectedBackups) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Count); + userSettings.Setup(m => m.DeleteBackupsCount).Returns(count); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(expectedBackups, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(expectedBackups + 1, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [DataTestMethod] + [DataRow(-10, -10, 30)] + [DataRow(-10, 0, 30)] + [DataRow(-10, 10, 5)] + [DataRow(0, -10, 30)] + [DataRow(0, 0, 30)] + [DataRow(0, 10, 5)] + [DataRow(10, -10, 30)] + [DataRow(10, 0, 30)] + [DataRow(5, 1, 5)] + [DataRow(1, 15, 10)] + [DataRow(2, 2, 2)] + public void Hosts_Backups_Delete_ByAge(int count, int days, int expectedBackups) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Age); + userSettings.Setup(m => m.DeleteBackupsCount).Returns(count); + userSettings.Setup(m => m.DeleteBackupsDays).Returns(days); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(expectedBackups, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(expectedBackups + 1, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + private void SetupFiles(MockFileSystem fileSystem, bool hostsOnly) + { + fileSystem.AddDirectory(BackupPath); + fileSystem.AddFile(HostsPath, new MockFileData("HOSTS FILE CONTENT")); + + if (hostsOnly) + { + return; + } + + var today = new DateTimeOffset(DateTime.Today); + + var notBackupData = new MockFileData("NOT A BACKUP") + { + CreationTime = today.AddDays(-100), + }; + + fileSystem.AddFile(fileSystem.Path.Combine(BackupPath, "hosts_not_a_backup"), notBackupData); + + // The first backup is from 5 days ago. There are 30 backups, one for each day. + var offset = 5; + for (var i = 0; i < 30; i++) + { + var backupData = new MockFileData("THIS IS A BACKUP") + { + CreationTime = today.AddDays(-i - offset), + }; + + fileSystem.AddFile(fileSystem.Path.Combine(BackupPath, $"hosts_PowerToysBackup_{i}"), backupData); + } + } + } +} diff --git a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs index 81052fd101..4c6ee77f8c 100644 --- a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs +++ b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs @@ -20,8 +20,10 @@ namespace Hosts.Tests [TestClass] public class HostsServiceTest { + private const string BackupPath = @"C:\Backup\hosts"; private static Mock _userSettings; private static Mock _elevationHelper; + private static Mock _backupManager; [ClassInitialize] public static void ClassInitialize(TestContext context) @@ -29,27 +31,7 @@ namespace Hosts.Tests _userSettings = new Mock(); _elevationHelper = new Mock(); _elevationHelper.Setup(m => m.IsElevated).Returns(true); - } - - [TestMethod] - public void Hosts_Exists() - { - var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); - fileSystem.AddFile(service.HostsFilePath, new MockFileData(string.Empty)); - var result = service.Exists(); - - Assert.IsTrue(result); - } - - [TestMethod] - public void Hosts_Not_Exists() - { - var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); - var result = service.Exists(); - - Assert.IsFalse(result); + _backupManager = new Mock(); } [TestMethod] @@ -67,7 +49,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -92,7 +74,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -118,7 +100,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -137,7 +119,7 @@ namespace Hosts.Tests public async Task Empty_Hosts() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(string.Empty)); await service.WriteAsync(string.Empty, Enumerable.Empty()); @@ -168,7 +150,7 @@ namespace Hosts.Tests var fileSystem = new CustomMockFileSystem(); var userSettings = new Mock(); userSettings.Setup(m => m.AdditionalLinesPosition).Returns(HostsAdditionalLinesPosition.Top); - var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -200,7 +182,7 @@ namespace Hosts.Tests var fileSystem = new CustomMockFileSystem(); var userSettings = new Mock(); userSettings.Setup(m => m.AdditionalLinesPosition).Returns(HostsAdditionalLinesPosition.Bottom); - var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -224,7 +206,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -241,7 +223,7 @@ namespace Hosts.Tests var elevationHelper = new Mock(); elevationHelper.Setup(m => m.IsElevated).Returns(false); - var service = new HostsService(fileSystem, _userSettings.Object, elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, elevationHelper.Object, _backupManager.Object); await Assert.ThrowsExceptionAsync(async () => await service.WriteAsync("# Empty hosts file", Enumerable.Empty())); } @@ -249,7 +231,7 @@ namespace Hosts.Tests public async Task Save_ReadOnlyHostsException() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -265,7 +247,7 @@ namespace Hosts.Tests public void Remove_ReadOnly_Attribute() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -284,7 +266,7 @@ namespace Hosts.Tests public async Task Save_Hidden_Hosts() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -316,7 +298,7 @@ namespace Hosts.Tests var fs = new CustomMockFileSystem(); var settings = new Mock(); settings.Setup(s => s.NoLeadingSpaces).Returns(true); - var svc = new HostsService(fs, settings.Object, _elevationHelper.Object); + var svc = new HostsService(fs, settings.Object, _elevationHelper.Object, _backupManager.Object); fs.AddFile(svc.HostsFilePath, new MockFileData(content)); var data = await svc.ReadAsync(); @@ -327,5 +309,57 @@ namespace Hosts.Tests var result = fs.GetFile(svc.HostsFilePath); Assert.AreEqual(expected, result.TextContents); } + + [TestMethod] + public async Task Hosts_Backup_Not_Executed() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new CustomMockFileSystem(); + fileSystem.AddDirectory(BackupPath); + _userSettings.Setup(m => m.BackupHosts).Returns(false); + _userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, _userSettings.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, backupManager); + + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var data = await service.ReadAsync(); + var entries = data.Entries.ToList(); + entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false)); + await service.WriteAsync(data.AdditionalLines, data.Entries); + + Assert.AreEqual(0, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [TestMethod] + public async Task Hosts_Backup_Executed_Once() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new CustomMockFileSystem(); + _userSettings.Setup(m => m.BackupHosts).Returns(true); + _userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, _userSettings.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, backupManager); + + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var data = await service.ReadAsync(); + var entries = data.Entries.ToList(); + entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false)); + await service.WriteAsync(data.AdditionalLines, data.Entries); + await service.WriteAsync(data.AdditionalLines, data.Entries); + + Assert.AreEqual(1, fileSystem.Directory.GetFiles(BackupPath).Length); + var backupContent = fileSystem.File.ReadAllText(fileSystem.Directory.GetFiles(BackupPath)[0]); + Assert.AreEqual(content, backupContent); + } } } diff --git a/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs b/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs index fbe5d3662d..8dbec70de8 100644 --- a/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs +++ b/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs @@ -56,6 +56,7 @@ namespace Hosts { // Core Services services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -74,7 +75,7 @@ namespace Hosts }). Build(); - var cleanupBackupThread = new Thread(() => + var deleteBackupThread = new Thread(() => { // Delete old backups only if running elevated if (!Host.GetService().IsElevated) @@ -84,7 +85,7 @@ namespace Hosts try { - Host.GetService().CleanupBackup(); + Host.GetService().Delete(); } catch (Exception ex) { @@ -92,8 +93,8 @@ namespace Hosts } }); - cleanupBackupThread.IsBackground = true; - cleanupBackupThread.Start(); + deleteBackupThread.IsBackground = true; + deleteBackupThread.Start(); UnhandledException += App_UnhandledException; diff --git a/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml b/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml index 92d1594556..001cbeb3ed 100644 --- a/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml +++ b/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml @@ -20,7 +20,7 @@ - + - + 16.0 @@ -46,7 +46,7 @@ - $(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories) + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) diff --git a/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs b/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs new file mode 100644 index 0000000000..5417408409 --- /dev/null +++ b/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO.Abstractions; +using System.Linq; +using HostsUILib.Settings; + +namespace HostsUILib.Helpers +{ + public class BackupManager : IBackupManager + { + private const string BackupSuffix = "_PowerToysBackup_"; + private readonly IFileSystem _fileSystem; + private readonly IUserSettings _userSettings; + private bool _backupDone; + + public BackupManager(IFileSystem fileSystem, IUserSettings userSettings) + { + _fileSystem = fileSystem; + _userSettings = userSettings; + } + + public void Create(string hostsFilePath) + { + if (_backupDone || !_userSettings.BackupHosts || !_fileSystem.File.Exists(hostsFilePath)) + { + return; + } + + try + { + if (!_fileSystem.Directory.Exists(_userSettings.BackupPath)) + { + _fileSystem.Directory.CreateDirectory(_userSettings.BackupPath); + } + + var backupPath = _fileSystem.Path.Combine(_userSettings.BackupPath, $"hosts{BackupSuffix}{DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}"); + + _fileSystem.File.Copy(hostsFilePath, backupPath); + _backupDone = true; + } + catch (Exception ex) + { + LoggerInstance.Logger.LogError("Backup failed", ex); + } + } + + public void Delete() + { + switch (_userSettings.DeleteBackupsMode) + { + case HostsDeleteBackupMode.Count: + DeleteByCount(_userSettings.DeleteBackupsCount); + break; + case HostsDeleteBackupMode.Age: + DeleteByAge(_userSettings.DeleteBackupsDays, _userSettings.DeleteBackupsCount); + break; + } + } + + public void DeleteByCount(int count) + { + if (count < 1) + { + return; + } + + var backups = GetAll().OrderByDescending(f => f.CreationTime).Skip(count).ToArray(); + DeleteAll(backups); + } + + public void DeleteByAge(int days, int count) + { + if (days < 1) + { + return; + } + + var backupsEnumerable = GetAll(); + + if (count > 0) + { + backupsEnumerable = backupsEnumerable.OrderByDescending(f => f.CreationTime).Skip(count); + } + + var backups = backupsEnumerable.Where(f => f.CreationTime < DateTime.Now.AddDays(-days)).ToArray(); + DeleteAll(backups); + } + + private IEnumerable GetAll() + { + if (!_fileSystem.Directory.Exists(_userSettings.BackupPath)) + { + return []; + } + + return _fileSystem.Directory.GetFiles(_userSettings.BackupPath, $"*{BackupSuffix}*").Select(_fileSystem.FileInfo.New); + } + + private void DeleteAll(IFileInfo[] files) + { + foreach (var f in files) + { + _fileSystem.File.Delete(f.FullName); + } + } + } +} diff --git a/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs b/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs index 83aa3544b1..9b16e04f20 100644 --- a/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs +++ b/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -23,16 +22,15 @@ namespace HostsUILib.Helpers { public partial class HostsService : IHostsService, IDisposable { - private const string _backupSuffix = $"_PowerToysBackup_"; - private const int _defaultBufferSize = 4096; // From System.IO.File source code + private const int DefaultBufferSize = 4096; // From System.IO.File source code private readonly SemaphoreSlim _asyncLock = new SemaphoreSlim(1, 1); private readonly IFileSystem _fileSystem; private readonly IUserSettings _userSettings; private readonly IElevationHelper _elevationHelper; private readonly IFileSystemWatcher _fileSystemWatcher; + private readonly IBackupManager _backupManager; private readonly string _hostsFilePath; - private bool _backupDone; private bool _disposed; public string HostsFilePath => _hostsFilePath; @@ -44,11 +42,13 @@ namespace HostsUILib.Helpers public HostsService( IFileSystem fileSystem, IUserSettings userSettings, - IElevationHelper elevationHelper) + IElevationHelper elevationHelper, + IBackupManager backupManager) { _fileSystem = fileSystem; _userSettings = userSettings; _elevationHelper = elevationHelper; + _backupManager = backupManager; _hostsFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"System32\drivers\etc\hosts"); @@ -60,18 +60,13 @@ namespace HostsUILib.Helpers _fileSystemWatcher.EnableRaisingEvents = true; } - public bool Exists() - { - return _fileSystem.File.Exists(HostsFilePath); - } - public async Task ReadAsync() { var entries = new List(); var unparsedBuilder = new StringBuilder(); var splittedEntries = false; - if (!Exists()) + if (!_fileSystem.File.Exists(HostsFilePath)) { return new HostsData(entries, unparsedBuilder.ToString(), false); } @@ -192,15 +187,10 @@ namespace HostsUILib.Helpers { await _asyncLock.WaitAsync(); _fileSystemWatcher.EnableRaisingEvents = false; - - if (!_backupDone && Exists()) - { - _fileSystem.File.Copy(HostsFilePath, HostsFilePath + _backupSuffix + DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)); - _backupDone = true; - } + _backupManager.Create(HostsFilePath); // FileMode.OpenOrCreate is necessary to prevent UnauthorizedAccessException when the hosts file is hidden - using var stream = _fileSystem.FileStream.New(HostsFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read, _defaultBufferSize, FileOptions.Asynchronous); + using var stream = _fileSystem.FileStream.New(HostsFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read, DefaultBufferSize, FileOptions.Asynchronous); using var writer = new StreamWriter(stream, Encoding); foreach (var line in lines) { @@ -231,15 +221,6 @@ namespace HostsUILib.Helpers } } - public void CleanupBackup() - { - Directory.GetFiles(Path.GetDirectoryName(HostsFilePath), $"*{_backupSuffix}*") - .Select(f => new FileInfo(f)) - .Where(f => f.CreationTime < DateTime.Now.AddDays(-15)) - .ToList() - .ForEach(f => f.Delete()); - } - public void OpenHostsFile() { var notepadFallback = false; diff --git a/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.cs b/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.cs new file mode 100644 index 0000000000..9da9802a26 --- /dev/null +++ b/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace HostsUILib.Helpers +{ + public interface IBackupManager + { + void Create(string hostsFilePath); + + void Delete(); + } +} diff --git a/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs b/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs index fe75946a12..c6f2678156 100644 --- a/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs +++ b/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs @@ -22,8 +22,6 @@ namespace HostsUILib.Helpers Task PingAsync(string address); - void CleanupBackup(); - void OpenHostsFile(); void RemoveReadOnlyAttribute(); diff --git a/src/modules/Hosts/HostsUILib/HostsMainPage.xaml b/src/modules/Hosts/HostsUILib/HostsMainPage.xaml index 762b4264c9..77b71ef5f1 100644 --- a/src/modules/Hosts/HostsUILib/HostsMainPage.xaml +++ b/src/modules/Hosts/HostsUILib/HostsMainPage.xaml @@ -27,160 +27,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +#include "resource.h" +#include "../../../common/version/version.h" + +1 VERSIONINFO +FILEVERSION FILE_VERSION +PRODUCTVERSION PRODUCT_VERSION +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG +FILEFLAGS VS_FF_DEBUG +#else +FILEFLAGS 0x0L +#endif +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_DLL +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", FILE_DESCRIPTION + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", INTERNAL_NAME + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", ORIGINAL_FILENAME + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset + END +END \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj new file mode 100644 index 0000000000..261cfab1e6 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj @@ -0,0 +1,225 @@ + + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + 15.0 + {38177d56-6ad1-4adf-88c9-2843a7932166} + Win32Proj + LightSwitchModuleInterface + 10.0 + LightSwitchModuleInterface + PowerToys.LightSwitchModuleInterface + + + + DynamicLibrary + true + v142 + Unicode + + + DynamicLibrary + false + v142 + true + Unicode + + + DynamicLibrary + true + v142 + Unicode + + + DynamicLibrary + false + v142 + true + Unicode + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\ + + + true + + + false + + + + Use + Level3 + Disabled + true + _DEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreadedDebug + stdcpplatest + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Use + Level3 + MaxSpeed + true + true + true + NDEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreaded + stdcpplatest + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Use + Level3 + Disabled + true + _DEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreadedDebug + stdcpplatest + Use + pch.h + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Use + Level3 + MaxSpeed + true + true + true + NDEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreaded + stdcpplatest + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + $(CoreLibraryDependencies);%(AdditionalDependencies);advapi32.lib + + + + + + + + + + + + Create + Create + Create + Create + pch.h + pch.h + pch.h + pch.h + + + + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {4aed67b6-55fd-486f-b917-e543dee2cb3c} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters new file mode 100644 index 0000000000..45352efe4b --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters @@ -0,0 +1,50 @@ + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + {bbf22ac8-46f8-4206-b44b-9c3897e99ce5} + + + {530ed784-9a70-46a0-8fb6-20d5dee4f7d3} + + + {da1cb871-86d3-414c-adf5-a7e9f2077d2f} + + + + + Resource Files + + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.cpp new file mode 100644 index 0000000000..3593a5bbae --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.cpp @@ -0,0 +1,106 @@ +#include "pch.h" +#include +#include "ThemeHelper.h" + +// Controls changing the themes. +static void ResetColorPrevalence() +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = 0; // back to default value + RegSetValueEx(hKey, L"ColorPrevalence", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_DWMCOLORIZATIONCOLORCHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +void SetAppsTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"AppsUseLightTheme", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +void SetSystemTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"SystemUsesLightTheme", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + if (mode) // if are changing to light mode + { + ResetColorPrevalence(); + } + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +bool GetCurrentSystemTheme() +{ + HKEY hKey; + DWORD value = 1; // default = light + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"SystemUsesLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} + +bool GetCurrentAppsTheme() +{ + HKEY hKey; + DWORD value = 1; + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"AppsUseLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.h new file mode 100644 index 0000000000..5985fd95c8 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.h @@ -0,0 +1,5 @@ +#pragma once +void SetSystemTheme(bool dark); +void SetAppsTheme(bool dark); +bool GetCurrentSystemTheme(); +bool GetCurrentAppsTheme(); diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp new file mode 100644 index 0000000000..170dde5b0a --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -0,0 +1,642 @@ +#include "pch.h" +#include +#include "trace.h" +#include +#include +#include +#include +#include +#include +#include "ThemeHelper.h" + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_WIN[] = L"win"; + const wchar_t JSON_KEY_ALT[] = L"alt"; + const wchar_t JSON_KEY_CTRL[] = L"ctrl"; + const wchar_t JSON_KEY_SHIFT[] = L"shift"; + const wchar_t JSON_KEY_CODE[] = L"code"; + const wchar_t JSON_KEY_TOGGLE_THEME_HOTKEY[] = L"toggle-theme-hotkey"; + const wchar_t JSON_KEY_VALUE[] = L"value"; +} + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +// The PowerToy name that will be shown in the settings. +const static wchar_t* MODULE_NAME = L"LightSwitch"; +// Add a description that will we shown in the module settings page. +const static wchar_t* MODULE_DESC = L"This is a module that allows you to control light/dark theming via set times, sun rise, or directly invoking the change."; + +enum class ScheduleMode +{ + Off, + FixedHours, + SunsetToSunrise, + // add more later +}; + +inline std::wstring ToString(ScheduleMode mode) +{ + switch (mode) + { + case ScheduleMode::SunsetToSunrise: + return L"SunsetToSunrise"; + case ScheduleMode::FixedHours: + return L"FixedHours"; + default: + return L"Off"; + } +} + +inline ScheduleMode FromString(const std::wstring& str) +{ + if (str == L"SunsetToSunrise") + return ScheduleMode::SunsetToSunrise; + if (str == L"FixedHours") + return ScheduleMode::FixedHours; + return ScheduleMode::Off; +} + +// These are the properties shown in the Settings page. +struct ModuleSettings +{ + bool m_changeSystem = true; + bool m_changeApps = true; + ScheduleMode m_scheduleMode = ScheduleMode::Off; + int m_lightTime = 480; + int m_darkTime = 1200; + int m_sunrise_offset = 0; + int m_sunset_offset = 0; + std::wstring m_latitude = L"0.0"; + std::wstring m_longitude = L"0.0"; +} g_settings; + +class LightSwitchInterface : public PowertoyModuleIface +{ +private: + bool m_enabled = false; + + HANDLE m_process{ nullptr }; + HANDLE m_force_light_event_handle; + HANDLE m_force_dark_event_handle; + HANDLE m_manual_override_event_handle; + + static const constexpr int NUM_DEFAULT_HOTKEYS = 4; + + Hotkey m_toggle_theme_hotkey = { .win = true, .ctrl = true, .shift = true, .alt = false, .key = 'D' }; + + void init_settings(); + +public: + LightSwitchInterface() + { + LoggerHelpers::init_logger(L"LightSwitch", L"ModuleInterface", LogSettings::lightSwitchLoggerName); + + m_force_light_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_LIGHT"); + m_force_dark_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_DARK"); + m_manual_override_event_handle = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + + init_settings(); + }; + + virtual const wchar_t* get_key() override + { + return L"LightSwitch"; + } + + // Destroy the powertoy and free memory + virtual void destroy() override + { + delete this; + } + + // Return the display name of the powertoy, this will be cached by the runner + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + // Return the configured status for the gpo policy for the module + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredLightSwitchEnabledValue(); + } + + // Return JSON with the configuration options. + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + + // Create a Settings object with your module name + PowerToysSettings::Settings settings(hinstance, get_name()); + settings.set_description(MODULE_DESC); + settings.set_overview_link(L"https://aka.ms/powertoys"); + + // Boolean toggles + settings.add_bool_toggle( + L"changeSystem", + L"Change System Theme", + g_settings.m_changeSystem); + + settings.add_bool_toggle( + L"changeApps", + L"Change Apps Theme", + g_settings.m_changeApps); + + settings.add_choice_group( + L"scheduleMode", + L"Theme schedule mode", + ToString(g_settings.m_scheduleMode), + { { L"Off", L"Disable the schedule" }, + { L"FixedHours", L"Set hours manually" }, + { L"SunsetToSunrise", L"Use sunrise/sunset times" } }); + + // Integer spinners + settings.add_int_spinner( + L"lightTime", + L"Time to switch to light theme (minutes after midnight).", + g_settings.m_lightTime, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"darkTime", + L"Time to switch to dark theme (minutes after midnight).", + g_settings.m_darkTime, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"sunrise_offset", + L"Time to offset turning on your light theme.", + g_settings.m_sunrise_offset, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"sunset_offset", + L"Time to offset turning on your dark theme.", + g_settings.m_sunset_offset, + 0, + 1439, + 1); + + // Strings for latitude and longitude + settings.add_string( + L"latitude", + L"Your latitude in decimal degrees (e.g. 39.95).", + g_settings.m_latitude); + + settings.add_string( + L"longitude", + L"Your longitude in decimal degrees (e.g. -75.16).", + g_settings.m_longitude); + + // One-shot actions (buttons) + settings.add_custom_action( + L"forceLight", + L"Switch immediately to light theme", + L"Force Light", + L"{}"); + + settings.add_custom_action( + L"forceDark", + L"Switch immediately to dark theme", + L"Force Dark", + L"{}"); + + // Hotkeys + PowerToysSettings::HotkeyObject dm_hk = PowerToysSettings::HotkeyObject::from_settings( + m_toggle_theme_hotkey.win, + m_toggle_theme_hotkey.ctrl, + m_toggle_theme_hotkey.alt, + m_toggle_theme_hotkey.shift, + m_toggle_theme_hotkey.key); + + settings.add_hotkey( + L"toggle-theme-hotkey", + L"Shortcut to toggle theme immediately", + dm_hk); + + // Serialize to buffer for the PowerToys runner + return settings.serialize_to_buffer(buffer, buffer_size); + } + + // Signal from the Settings editor to call a custom action. + // This can be used to spawn more complex editors. + void call_custom_action(const wchar_t* action) override + { + try + { + auto action_object = PowerToysSettings::CustomActionObject::from_json_string(action); + + if (action_object.get_name() == L"forceLight") + { + Logger::info(L"[Light Switch] Custom action triggered: Force Light"); + SetSystemTheme(true); + SetAppsTheme(true); + } + else if (action_object.get_name() == L"forceDark") + { + Logger::info(L"[Light Switch] Custom action triggered: Force Dark"); + SetSystemTheme(false); + SetAppsTheme(false); + } + } + catch (...) + { + Logger::error(L"[Light Switch] Invalid custom action JSON"); + } + } + + // Called by the runner to pass the updated settings values as a serialized JSON. + virtual void set_config(const wchar_t* config) override + { + try + { + auto values = PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + + parse_hotkey(values); + + if (auto v = values.get_bool_value(L"changeSystem")) + { + g_settings.m_changeSystem = *v; + } + + if (auto v = values.get_bool_value(L"changeApps")) + { + g_settings.m_changeApps = *v; + } + + auto previousMode = g_settings.m_scheduleMode; + + if (auto v = values.get_string_value(L"scheduleMode")) + { + auto newMode = FromString(*v); + if (newMode != g_settings.m_scheduleMode) + { + Logger::info(L"[LightSwitchInterface] Schedule mode changed from {} to {}", + ToString(g_settings.m_scheduleMode), + ToString(newMode)); + g_settings.m_scheduleMode = newMode; + + start_service_if_needed(); + } + } + + if (auto v = values.get_int_value(L"lightTime")) + { + g_settings.m_lightTime = *v; + } + + if (auto v = values.get_int_value(L"darkTime")) + { + g_settings.m_darkTime = *v; + } + + if (auto v = values.get_int_value(L"sunrise_offset")) + { + g_settings.m_sunrise_offset = *v; + } + + if (auto v = values.get_int_value(L"sunset_offset")) + { + g_settings.m_sunset_offset = *v; + } + + if (auto v = values.get_string_value(L"latitude")) + { + g_settings.m_latitude = *v; + } + if (auto v = values.get_string_value(L"longitude")) + { + g_settings.m_longitude = *v; + } + + values.save_to_settings_file(); + } + catch (const std::exception&) + { + Logger::error("[Light Switch] set_config: Failed to parse or apply config."); + } + } + + virtual void start_service_if_needed() + { + if (!m_process || WaitForSingleObject(m_process, 0) != WAIT_TIMEOUT) + { + Logger::info(L"[LightSwitchInterface] Starting LightSwitchService due to active schedule mode."); + enable(); + } + else + { + Logger::debug(L"[LightSwitchInterface] Service already running, skipping start."); + } + } + + /*virtual void stop_worker_only() + { + if (m_process) + { + Logger::info(L"[LightSwitchInterface] Stopping LightSwitchService (worker only)."); + constexpr DWORD timeout_ms = 1500; + DWORD result = WaitForSingleObject(m_process, timeout_ms); + + if (result == WAIT_TIMEOUT) + { + Logger::warn("Light Switch: Process didn't exit in time. Forcing termination."); + TerminateProcess(m_process, 0); + } + + CloseHandle(m_process); + m_process = nullptr; + } + }*/ + + /*virtual void stop_service_if_running() + { + if (m_process) + { + Logger::info(L"[LightSwitchInterface] Stopping LightSwitchService due to schedule OFF."); + stop_worker_only(); + } + }*/ + + virtual void enable() + { + m_enabled = true; + Logger::info(L"Enabling Light Switch module..."); + + unsigned long powertoys_pid = GetCurrentProcessId(); + std::wstring args = L"--pid " + std::to_wstring(powertoys_pid); + std::wstring exe_name = L"LightSwitchService\\PowerToys.LightSwitchService.exe"; + + std::wstring resolved_path(MAX_PATH, L'\0'); + DWORD result = SearchPathW( + nullptr, + exe_name.c_str(), + nullptr, + static_cast(resolved_path.size()), + resolved_path.data(), + nullptr); + + if (result == 0 || result >= resolved_path.size()) + { + Logger::error( + L"Failed to locate Light Switch executable named '{}' at location '{}'", + exe_name, + resolved_path.c_str()); + return; + } + + resolved_path.resize(result); + Logger::debug(L"Resolved executable path: {}", resolved_path); + + std::wstring command_line = L"\"" + resolved_path + L"\" " + args; + + STARTUPINFO si = { sizeof(si) }; + PROCESS_INFORMATION pi; + + if (!CreateProcessW( + resolved_path.c_str(), + command_line.data(), + nullptr, + nullptr, + TRUE, + 0, + nullptr, + nullptr, + &si, + &pi)) + { + Logger::error(L"Failed to launch Light Switch process. {}", get_last_error_or_default(GetLastError())); + return; + } + + Logger::info(L"Light Switch process launched successfully (PID: {}).", pi.dwProcessId); + m_process = pi.hProcess; + CloseHandle(pi.hThread); + } + + // Disable the powertoy + virtual void disable() + { + Logger::info("Light Switch disabling"); + m_enabled = false; + + if (m_process) + { + constexpr DWORD timeout_ms = 1500; + DWORD result = WaitForSingleObject(m_process, timeout_ms); + + if (result == WAIT_TIMEOUT) + { + Logger::warn("Light Switch: Process didn't exit in time. Forcing termination."); + TerminateProcess(m_process, 0); + } + + CloseHandle(m_manual_override_event_handle); + m_manual_override_event_handle = nullptr; + + CloseHandle(m_process); + m_process = nullptr; + } + } + + // Returns if the powertoys is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + // Returns whether the PowerToys should be enabled by default + virtual bool is_enabled_by_default() const override + { + return false; + } + + void parse_hotkey(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + Hotkey _temp_toggle_theme; + auto jsonHotkeyObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_TOGGLE_THEME_HOTKEY).GetNamedObject(JSON_KEY_VALUE); + _temp_toggle_theme.win = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_WIN); + _temp_toggle_theme.alt = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_ALT); + _temp_toggle_theme.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT); + _temp_toggle_theme.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL); + _temp_toggle_theme.key = static_cast(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE)); + m_toggle_theme_hotkey = _temp_toggle_theme; + } + catch (...) + { + Logger::error("Failed to initialize Light Switch force dark mode shortcut from settings. Value will keep unchanged."); + } + } + else + { + Logger::info("Light Switch settings are empty"); + } + } + + virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override + { + if (hotkeys && buffer_size >= 1) + { + hotkeys[0] = m_toggle_theme_hotkey; + } + return 1; + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (m_enabled) + { + Logger::trace(L"Light Switch hotkey pressed"); + if (!is_process_running()) + { + enable(); + } + else if (hotkeyId == 0) + { + // get current will return true if in light mode; otherwise false + Logger::info(L"[Light Switch] Hotkey triggered: Toggle Theme"); + if (g_settings.m_changeSystem) + { + SetSystemTheme(!GetCurrentSystemTheme()); + } + if (g_settings.m_changeApps) + { + SetAppsTheme(!GetCurrentAppsTheme()); + } + + if (!m_manual_override_event_handle) + { + m_manual_override_event_handle = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + if (!m_manual_override_event_handle) + { + m_manual_override_event_handle = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + } + } + + if (m_manual_override_event_handle) + { + SetEvent(m_manual_override_event_handle); + Logger::debug(L"[Light Switch] Manual override event set"); + } + } + + return true; + } + + return false; + } + + bool is_process_running() + { + return WaitForSingleObject(m_process, 0) == WAIT_TIMEOUT; + } +}; + +std::wstring utf8_to_wstring(const std::string& str) +{ + if (str.empty()) + return std::wstring(); + + int size_needed = MultiByteToWideChar( + CP_UTF8, + 0, + str.c_str(), + static_cast(str.size()), + nullptr, + 0); + + std::wstring wstr(size_needed, 0); + + MultiByteToWideChar( + CP_UTF8, + 0, + str.c_str(), + static_cast(str.size()), + &wstr[0], + size_needed); + + return wstr; +} + +// Load the settings file. +void LightSwitchInterface::init_settings() +{ + Logger::info(L"[Light Switch] init_settings: starting to load settings for module"); + + try + { + PowerToysSettings::PowerToyValues settings = + PowerToysSettings::PowerToyValues::load_from_settings_file(get_name()); + + parse_hotkey(settings); + + if (auto v = settings.get_bool_value(L"changeSystem")) + g_settings.m_changeSystem = *v; + if (auto v = settings.get_bool_value(L"changeApps")) + g_settings.m_changeApps = *v; + if (auto v = settings.get_string_value(L"scheduleMode")) + g_settings.m_scheduleMode = FromString(*v); + if (auto v = settings.get_int_value(L"lightTime")) + g_settings.m_lightTime = *v; + if (auto v = settings.get_int_value(L"darkTime")) + g_settings.m_darkTime = *v; + if (auto v = settings.get_int_value(L"sunrise_offset")) + g_settings.m_sunrise_offset = *v; + if (auto v = settings.get_int_value(L"sunset_offset")) + g_settings.m_sunset_offset = *v; + if (auto v = settings.get_string_value(L"latitude")) + g_settings.m_latitude = *v; + if (auto v = settings.get_string_value(L"longitude")) + g_settings.m_longitude = *v; + + Logger::info(L"[Light Switch] init_settings: loaded successfully"); + } + catch (const winrt::hresult_error& e) + { + Logger::error(L"[Light Switch] init_settings: hresult_error 0x{:08X} - {}", e.code(), e.message().c_str()); + } + catch (const std::exception& e) + { + std::wstring whatStr = utf8_to_wstring(e.what()); + Logger::error(L"[Light Switch] init_settings: std::exception - {}", whatStr); + } + catch (...) + { + Logger::error(L"[Light Switch] init_settings: unknown exception while loading settings"); + } +} + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new LightSwitchInterface(); +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp new file mode 100644 index 0000000000..a83d3bb2cc --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp @@ -0,0 +1,2 @@ +#include "pch.h" +#pragma comment(lib, "windowsapp") \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h new file mode 100644 index 0000000000..39f8f4ac84 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h @@ -0,0 +1,14 @@ +#pragma once +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/resource.h b/src/modules/LightSwitch/LightSwitchModuleInterface/resource.h new file mode 100644 index 0000000000..548cde844b --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/resource.h @@ -0,0 +1,13 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by CalculatorEngineCommon.rc + +////////////////////////////// +// Non-localizable + +#define FILE_DESCRIPTION "Light Switch Module" +#define INTERNAL_NAME "Light Switch" +#define ORIGINAL_FILENAME "PowerToys.LightSwitchModuleInterface.dll" + +// Non-localizable +////////////////////////////// diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp new file mode 100644 index 0000000000..57fa1921f7 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp @@ -0,0 +1,30 @@ +#include "pch.h" +#include "trace.h" +#include + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::MyEvent() +{ + TraceLoggingWrite( + g_hProvider, + "PowerToyName_MyEvent", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h new file mode 100644 index 0000000000..55cdedb2ee --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +TRACELOGGING_DECLARE_PROVIDER(g_hProvider); + +class Trace +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void MyEvent(); +}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico b/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico new file mode 100644 index 0000000000..ee1be50010 Binary files /dev/null and b/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico differ diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp new file mode 100644 index 0000000000..845e24fa93 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -0,0 +1,310 @@ +#include +#include +#include "ThemeScheduler.h" +#include "ThemeHelper.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "LightSwitchStateManager.h" +#include + +SERVICE_STATUS g_ServiceStatus = {}; +SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; +HANDLE g_ServiceStopEvent = nullptr; + +VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); +VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); +DWORD WINAPI ServiceWorkerThread(LPVOID lpParam); +void ApplyTheme(bool shouldBeLight); + +// Entry point for the executable +int _tmain(int argc, TCHAR* argv[]) +{ + DWORD parentPid = 0; + bool debug = false; + for (int i = 1; i < argc; ++i) + { + if (_tcscmp(argv[i], _T("--debug")) == 0) + debug = true; + else if (_tcscmp(argv[i], _T("--pid")) == 0 && i + 1 < argc) + parentPid = _tstoi(argv[++i]); + } + + // Try to connect to SCM + wchar_t serviceName[] = L"LightSwitchService"; + SERVICE_TABLE_ENTRYW table[] = { { serviceName, ServiceMain }, { nullptr, nullptr } }; + + LoggerHelpers::init_logger(L"LightSwitch", L"Service", LogSettings::lightSwitchLoggerName); + + if (!StartServiceCtrlDispatcherW(table)) + { + DWORD err = GetLastError(); + if (err == ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) // not launched by SCM + { + g_ServiceStopEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); + HANDLE hThread = CreateThread( + nullptr, 0, ServiceWorkerThread, reinterpret_cast(static_cast(parentPid)), 0, nullptr); + + // Wait so the process stays alive + WaitForSingleObject(hThread, INFINITE); + CloseHandle(hThread); + CloseHandle(g_ServiceStopEvent); + return 0; + } + return static_cast(err); + } + + return 0; +} + +// Called when the service is launched by Windows +VOID WINAPI ServiceMain(DWORD, LPTSTR*) +{ + g_StatusHandle = RegisterServiceCtrlHandler(_T("LightSwitchService"), ServiceCtrlHandler); + if (!g_StatusHandle) + return; + + g_ServiceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + g_ServiceStatus.dwControlsAccepted = SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN; + g_ServiceStatus.dwCurrentState = SERVICE_START_PENDING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + g_ServiceStopEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); + if (!g_ServiceStopEvent) + { + g_ServiceStatus.dwCurrentState = SERVICE_STOPPED; + g_ServiceStatus.dwWin32ExitCode = GetLastError(); + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + return; + } + + SECURITY_ATTRIBUTES sa{ sizeof(sa) }; + sa.bInheritHandle = FALSE; + sa.lpSecurityDescriptor = nullptr; + + g_ServiceStatus.dwCurrentState = SERVICE_RUNNING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + HANDLE hThread = CreateThread(nullptr, 0, ServiceWorkerThread, nullptr, 0, nullptr); + WaitForSingleObject(hThread, INFINITE); + CloseHandle(hThread); + + CloseHandle(g_ServiceStopEvent); + g_ServiceStatus.dwCurrentState = SERVICE_STOPPED; + g_ServiceStatus.dwWin32ExitCode = 0; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); +} + +VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl) +{ + switch (dwCtrl) + { + case SERVICE_CONTROL_STOP: + if (g_ServiceStatus.dwCurrentState != SERVICE_RUNNING) + break; + + g_ServiceStatus.dwCurrentState = SERVICE_STOP_PENDING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + // Signal the service to stop + Logger::info(L"[LightSwitchService] Stop requested, signaling worker thread to exit."); + SetEvent(g_ServiceStopEvent); + break; + + default: + break; + } +} + +void ApplyTheme(bool shouldBeLight) +{ + const auto& s = LightSwitchSettings::settings(); + + if (s.changeSystem) + { + bool isSystemCurrentlyLight = GetCurrentSystemTheme(); + if (shouldBeLight != isSystemCurrentlyLight) + { + SetSystemTheme(shouldBeLight); + Logger::info(L"[LightSwitchService] Changed system theme to {}.", shouldBeLight ? L"light" : L"dark"); + } + } + + if (s.changeApps) + { + bool isAppsCurrentlyLight = GetCurrentAppsTheme(); + if (shouldBeLight != isAppsCurrentlyLight) + { + SetAppsTheme(shouldBeLight); + Logger::info(L"[LightSwitchService] Changed apps theme to {}.", shouldBeLight ? L"light" : L"dark"); + } + } +} + +static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateManager) +{ + const auto& s = LightSwitchSettings::settings(); + if (s.scheduleMode == ScheduleMode::Off) + return; + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + // Compute effective boundaries (with offsets if needed) + int effectiveLight = s.lightTime; + int effectiveDark = s.darkTime; + + if (s.scheduleMode == ScheduleMode::SunsetToSunrise) + { + effectiveLight = (s.lightTime + s.sunrise_offset) % 1440; + effectiveDark = (s.darkTime + s.sunset_offset) % 1440; + } + + // Use shared helper (handles wraparound logic) + bool shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + + // Compare current system/apps theme + bool currentSystemLight = GetCurrentSystemTheme(); + bool currentAppsLight = GetCurrentAppsTheme(); + + bool systemMismatch = s.changeSystem && (currentSystemLight != shouldBeLight); + bool appsMismatch = s.changeApps && (currentAppsLight != shouldBeLight); + + // Trigger manual override only if mismatch and not already active + if ((systemMismatch || appsMismatch) && !stateManager.GetState().isManualOverride) + { + Logger::info(L"[LightSwitchService] External theme change detected (Windows Settings). Entering manual override mode."); + stateManager.OnManualOverride(); + } +} + +DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) +{ + DWORD parentPid = static_cast(reinterpret_cast(lpParam)); + HANDLE hParent = nullptr; + if (parentPid) + hParent = OpenProcess(SYNCHRONIZE, FALSE, parentPid); + + Logger::info(L"[LightSwitchService] Worker thread starting..."); + Logger::info(L"[LightSwitchService] Parent PID: {}", parentPid); + + // ──────────────────────────────────────────────────────────────── + // Initialization + // ──────────────────────────────────────────────────────────────── + static LightSwitchStateManager stateManager; + + LightSwitchSettings::instance().InitFileWatcher(); + + HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent(); + + LightSwitchSettings::instance().LoadSettings(); + const auto& settings = LightSwitchSettings::instance().settings(); + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + Logger::info(L"[LightSwitchService] Initialized at {:02d}:{:02d}.", st.wHour, st.wMinute); + stateManager.SyncInitialThemeState(); + stateManager.OnTick(nowMinutes); + + // ──────────────────────────────────────────────────────────────── + // Worker Loop + // ──────────────────────────────────────────────────────────────── + for (;;) + { + HANDLE waits[4]; + DWORD count = 0; + waits[count++] = g_ServiceStopEvent; + if (hParent) + waits[count++] = hParent; + if (hManualOverride) + waits[count++] = hManualOverride; + waits[count++] = hSettingsChanged; + + // Wait for one of these to trigger or for a new minute tick + SYSTEMTIME st; + GetLocalTime(&st); + int msToNextMinute = (60 - st.wSecond) * 1000 - st.wMilliseconds; + if (msToNextMinute < 50) + msToNextMinute = 50; + + DWORD wait = WaitForMultipleObjects(count, waits, FALSE, msToNextMinute); + + if (wait == WAIT_TIMEOUT) + { + // regular minute tick + GetLocalTime(&st); + nowMinutes = st.wHour * 60 + st.wMinute; + DetectAndHandleExternalThemeChange(stateManager); + stateManager.OnTick(nowMinutes); + continue; + } + + if (wait == WAIT_OBJECT_0) + { + Logger::info(L"[LightSwitchService] Stop event triggered — exiting."); + break; + } + + if (hParent && wait == WAIT_OBJECT_0 + 1) + { + Logger::info(L"[LightSwitchService] Parent process exited — stopping service."); + break; + } + + if (hManualOverride && wait == WAIT_OBJECT_0 + (hParent ? 2 : 1)) + { + Logger::info(L"[LightSwitchService] Manual override event detected."); + stateManager.OnManualOverride(); + ResetEvent(hManualOverride); + continue; + } + + if (wait == WAIT_OBJECT_0 + (hParent ? (hManualOverride ? 3 : 2) : 2)) + { + ResetEvent(hSettingsChanged); + LightSwitchSettings::instance().LoadSettings(); + stateManager.OnSettingsChanged(); + continue; + } + } + + // ──────────────────────────────────────────────────────────────── + // Cleanup + // ──────────────────────────────────────────────────────────────── + if (hManualOverride) + CloseHandle(hManualOverride); + if (hParent) + CloseHandle(hParent); + + Logger::info(L"[LightSwitchService] Worker thread exiting cleanly."); + return 0; +} + +int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) +{ + if (powertoys_gpo::getConfiguredLightSwitchEnabledValue() == powertoys_gpo::gpo_rule_configured_disabled) + { + wchar_t msg[160]; + swprintf_s( + msg, + L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator."); + Logger::info(msg); + return 0; + } + + int argc = 0; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + int rc = _tmain(argc, argv); // reuse your existing logic + LocalFree(argv); + return rc; +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc new file mode 100644 index 0000000000..c1914c5a5e Binary files /dev/null and b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc differ diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj new file mode 100644 index 0000000000..a3a505f897 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -0,0 +1,117 @@ + + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + 17.0 + Win32Proj + {08e71c67-6a7e-4ca1-b04e-2fb336410bac} + LightSwitchService + 10.0.26100.0 + LightSwitchService + + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + + ..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\ + PowerToys.LightSwitchService + + + + Level3 + true + true + NotUsing + %(PreprocessorDefinitions) + + ./../; + ..\..\..\common; + ..\..\..\common\logger; + ..\..\..\common\utils; + ..\..\..\common\SettingsAPI; + ..\..\..\common\Telemetry; + ..\..\..\; + ..\..\..\..\deps\spdlog\include; + ./; + %(AdditionalIncludeDirectories) + + + + Windows + true + Advapi32.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + + + + + + + + + + + + {4aed67b6-55fd-486f-b917-e543dee2cb3c} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + {1d5be09d-78c0-4fd7-af00-ae7c1af7c525} + + + {8f021b46-362b-485c-bfba-ccf83e820cbd} + + + + + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters new file mode 100644 index 0000000000..795df99aba --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -0,0 +1,74 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + + + + Resource Files + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp new file mode 100644 index 0000000000..5221a197fe --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp @@ -0,0 +1,249 @@ +#include "LightSwitchSettings.h" +#include +#include +#include "SettingsObserver.h" +#include +#include +#include + +using namespace std; + +LightSwitchSettings& LightSwitchSettings::instance() +{ + static LightSwitchSettings inst; + return inst; +} + +LightSwitchSettings::LightSwitchSettings() +{ + LoadSettings(); +} + +std::wstring LightSwitchSettings::GetSettingsFileName() +{ + return PTSettingsHelper::get_module_save_file_location(L"LightSwitch"); +} + +void LightSwitchSettings::InitFileWatcher() +{ + if (!m_settingsChangedEvent) + { + m_settingsChangedEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + } + + if (!m_settingsFileWatcher) + { + m_settingsFileWatcher = std::make_unique( + GetSettingsFileName(), + [this]() { + using namespace std::chrono; + + { + std::lock_guard lock(m_debounceMutex); + m_lastChangeTime = steady_clock::now(); + if (m_debouncePending) + return; + m_debouncePending = true; + } + + m_debounceThread = std::jthread([this](std::stop_token stop) { + using namespace std::chrono; + while (!stop.stop_requested()) + { + std::this_thread::sleep_for(seconds(3)); + + auto elapsed = steady_clock::now() - m_lastChangeTime; + if (elapsed >= seconds(1)) + break; + } + + { + std::lock_guard lock(m_debounceMutex); + m_debouncePending = false; + } + + Logger::info(L"[LightSwitchSettings] Settings file stabilized, reloading."); + + try + { + LoadSettings(); + SetEvent(m_settingsChangedEvent); + } + catch (const std::exception& e) + { + std::wstring wmsg; + wmsg.assign(e.what(), e.what() + strlen(e.what())); + Logger::error(L"[LightSwitchSettings] Exception during debounced reload: {}", wmsg); + } + }); + }); + } +} + +LightSwitchSettings::~LightSwitchSettings() +{ + Logger::info(L"[LightSwitchSettings] Cleaning up settings resources..."); + + // Stop and join the debounce thread (std::jthread auto-joins, but we can signal stop too) + if (m_debounceThread.joinable()) + { + m_debounceThread.request_stop(); + } + + // Release the file watcher so it closes file handles and background threads + if (m_settingsFileWatcher) + { + m_settingsFileWatcher.reset(); + Logger::info(L"[LightSwitchSettings] File watcher stopped."); + } + + // Close the Windows event handle + if (m_settingsChangedEvent) + { + CloseHandle(m_settingsChangedEvent); + m_settingsChangedEvent = nullptr; + Logger::info(L"[LightSwitchSettings] Settings changed event closed."); + } + + Logger::info(L"[LightSwitchSettings] Cleanup complete."); +} + + +void LightSwitchSettings::AddObserver(SettingsObserver& observer) +{ + m_observers.insert(&observer); +} + +void LightSwitchSettings::RemoveObserver(SettingsObserver& observer) +{ + m_observers.erase(&observer); +} + +void LightSwitchSettings::NotifyObservers(SettingId id) const +{ + for (auto observer : m_observers) + { + if (observer->WantsToBeNotified(id)) + { + observer->SettingsUpdate(id); + } + } +} + +HANDLE LightSwitchSettings::GetSettingsChangedEvent() const +{ + return m_settingsChangedEvent; +} + +void LightSwitchSettings::LoadSettings() +{ + std::lock_guard guard(m_settingsMutex); + try + { + PowerToysSettings::PowerToyValues values = + PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); + + + if (const auto jsonVal = values.get_string_value(L"scheduleMode")) + { + auto val = *jsonVal; + auto newMode = FromString(val); + if (m_settings.scheduleMode != newMode) + { + m_settings.scheduleMode = newMode; + NotifyObservers(SettingId::ScheduleMode); + } + } + + // Latitude + if (const auto jsonVal = values.get_string_value(L"latitude")) + { + auto val = *jsonVal; + if (m_settings.latitude != val) + { + m_settings.latitude = val; + NotifyObservers(SettingId::Latitude); + } + } + + // Longitude + if (const auto jsonVal = values.get_string_value(L"longitude")) + { + auto val = *jsonVal; + if (m_settings.longitude != val) + { + m_settings.longitude = val; + NotifyObservers(SettingId::Longitude); + } + } + + // LightTime + if (const auto jsonVal = values.get_int_value(L"lightTime")) + { + auto val = *jsonVal; + if (m_settings.lightTime != val) + { + m_settings.lightTime = val; + NotifyObservers(SettingId::LightTime); + } + } + + // DarkTime + if (const auto jsonVal = values.get_int_value(L"darkTime")) + { + auto val = *jsonVal; + if (m_settings.darkTime != val) + { + m_settings.darkTime = val; + NotifyObservers(SettingId::DarkTime); + } + } + + // Offset + if (const auto jsonVal = values.get_int_value(L"sunrise_offset")) + { + auto val = *jsonVal; + if (m_settings.sunrise_offset != val) + { + m_settings.sunrise_offset = val; + NotifyObservers(SettingId::Sunrise_Offset); + } + } + + if (const auto jsonVal = values.get_int_value(L"sunset_offset")) + { + auto val = *jsonVal; + if (m_settings.sunset_offset != val) + { + m_settings.sunset_offset = val; + NotifyObservers(SettingId::Sunset_Offset); + } + } + + // ChangeSystem + if (const auto jsonVal = values.get_bool_value(L"changeSystem")) + { + auto val = *jsonVal; + if (m_settings.changeSystem != val) + { + m_settings.changeSystem = val; + NotifyObservers(SettingId::ChangeSystem); + } + } + + // ChangeApps + if (const auto jsonVal = values.get_bool_value(L"changeApps")) + { + auto val = *jsonVal; + if (m_settings.changeApps != val) + { + m_settings.changeApps = val; + NotifyObservers(SettingId::ChangeApps); + } + } + } + catch (...) + { + // Keeps defaults if load fails + } +} diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h new file mode 100644 index 0000000000..d4029d072d --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class SettingsObserver; + +enum class ScheduleMode +{ + Off, + FixedHours, + SunsetToSunrise + // Add more in the future +}; + +inline std::wstring ToString(ScheduleMode mode) +{ + switch (mode) + { + case ScheduleMode::FixedHours: + return L"FixedHours"; + case ScheduleMode::SunsetToSunrise: + return L"SunsetToSunrise"; + default: + return L"Off"; + } +} + +inline ScheduleMode FromString(const std::wstring& str) +{ + if (str == L"SunsetToSunrise") + return ScheduleMode::SunsetToSunrise; + if (str == L"FixedHours") + return ScheduleMode::FixedHours; + else + return ScheduleMode::Off; +} + +struct LightSwitchConfig +{ + ScheduleMode scheduleMode = ScheduleMode::FixedHours; + + std::wstring latitude = L"0.0"; + std::wstring longitude = L"0.0"; + + // Stored as minutes since midnight + int lightTime = 8 * 60; // 08:00 default + int darkTime = 20 * 60; // 20:00 default + + int sunrise_offset = 0; + int sunset_offset = 0; + + bool changeSystem = false; + bool changeApps = false; +}; + +class LightSwitchSettings +{ +public: + static LightSwitchSettings& instance(); + + static inline const LightSwitchConfig& settings() + { + return instance().m_settings; + } + + void InitFileWatcher(); + static std::wstring GetSettingsFileName(); + + void AddObserver(SettingsObserver& observer); + void RemoveObserver(SettingsObserver& observer); + + void LoadSettings(); + + HANDLE GetSettingsChangedEvent() const; + +private: + LightSwitchSettings(); + ~LightSwitchSettings(); + + LightSwitchConfig m_settings; + std::unique_ptr m_settingsFileWatcher; + std::unordered_set m_observers; + + void NotifyObservers(SettingId id) const; + + HANDLE m_settingsChangedEvent = nullptr; + mutable std::mutex m_settingsMutex; + + // Debounce state + std::atomic_bool m_debouncePending{ false }; + std::mutex m_debounceMutex; + std::chrono::steady_clock::time_point m_lastChangeTime{}; + std::jthread m_debounceThread; +}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp new file mode 100644 index 0000000000..4fba4ae9a6 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -0,0 +1,232 @@ +#include "pch.h" +#include "LightSwitchStateManager.h" +#include +#include +#include "ThemeScheduler.h" +#include + +void ApplyTheme(bool shouldBeLight); + +// Constructor +LightSwitchStateManager::LightSwitchStateManager() +{ + Logger::info(L"[LightSwitchStateManager] Initialized"); +} + +// Called when settings.json changes +void LightSwitchStateManager::OnSettingsChanged() +{ + std::lock_guard lock(_stateMutex); + + // If manual override was active, clear it so new settings take effect + if (_state.isManualOverride) + { + _state.isManualOverride = false; + } + + EvaluateAndApplyIfNeeded(); +} + +// Called once per minute +void LightSwitchStateManager::OnTick(int currentMinutes) +{ + std::lock_guard lock(_stateMutex); + EvaluateAndApplyIfNeeded(); +} + +// Called when manual override is triggered +void LightSwitchStateManager::OnManualOverride() +{ + std::lock_guard lock(_stateMutex); + Logger::info(L"[LightSwitchStateManager] Manual override triggered"); + _state.isManualOverride = !_state.isManualOverride; + + // When entering manual override, sync internal theme state to match the current system + if (_state.isManualOverride) + { + _state.isSystemLightActive = GetCurrentSystemTheme(); + + _state.isAppsLightActive = GetCurrentAppsTheme(); + + Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).", + (_state.isSystemLightActive ? L"light" : L"dark"), + (_state.isAppsLightActive ? L"light" : L"dark")); + } + + EvaluateAndApplyIfNeeded(); +} + +// Helpers +bool LightSwitchStateManager::CoordinatesAreValid(const std::wstring& lat, const std::wstring& lon) +{ + try + { + double latVal = std::stod(lat); + double lonVal = std::stod(lon); + return !(latVal == 0 && lonVal == 0) && (latVal >= -90.0 && latVal <= 90.0) && (lonVal >= -180.0 && lonVal <= 180.0); + } + catch (...) + { + return false; + } +} + +void LightSwitchStateManager::SyncInitialThemeState() +{ + std::lock_guard lock(_stateMutex); + _state.isSystemLightActive = GetCurrentSystemTheme(); + _state.isAppsLightActive = GetCurrentAppsTheme(); + Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})", + _state.isSystemLightActive ? L"light" : L"dark"); + Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})", + _state.isAppsLightActive ? L"light" : L"dark"); +} + +static std::pair update_sun_times(auto& settings) +{ + double latitude = std::stod(settings.latitude); + double longitude = std::stod(settings.longitude); + + SYSTEMTIME st; + GetLocalTime(&st); + + SunTimes newTimes = CalculateSunriseSunset(latitude, longitude, st.wYear, st.wMonth, st.wDay); + + int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute; + int newDarkTime = newTimes.sunsetHour * 60 + newTimes.sunsetMinute; + + try + { + auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); + values.add_property(L"lightTime", newLightTime); + values.add_property(L"darkTime", newDarkTime); + values.save_to_settings_file(); + + Logger::info(L"[LightSwitchService] Updated sun times and saved to config."); + } + catch (const std::exception& e) + { + std::string msg = e.what(); + std::wstring wmsg(msg.begin(), msg.end()); + Logger::error(L"[LightSwitchService] Exception during sun time update: {}", wmsg); + } + + return { newLightTime, newDarkTime }; +} + +// Internal: decide what should happen now +void LightSwitchStateManager::EvaluateAndApplyIfNeeded() +{ + LightSwitchSettings::instance().LoadSettings(); + const auto& _currentSettings = LightSwitchSettings::settings(); + auto now = GetNowMinutes(); + + // Early exit: OFF mode just pauses activity + if (_currentSettings.scheduleMode == ScheduleMode::Off) + { + _state.lastTickMinutes = now; + return; + } + + bool coordsValid = CoordinatesAreValid(_currentSettings.latitude, _currentSettings.longitude); + + // Handle Sun Mode recalculation + if (_currentSettings.scheduleMode == ScheduleMode::SunsetToSunrise && coordsValid) + { + SYSTEMTIME st; + GetLocalTime(&st); + bool newDay = (_state.lastEvaluatedDay != st.wDay); + bool modeChangedToSun = (_state.lastAppliedMode != ScheduleMode::SunsetToSunrise && + _currentSettings.scheduleMode == ScheduleMode::SunsetToSunrise); + + if (newDay || modeChangedToSun) + { + auto [newLightTime, newDarkTime] = update_sun_times(_currentSettings); + _state.lastEvaluatedDay = st.wDay; + _state.effectiveLightMinutes = newLightTime + _currentSettings.sunrise_offset; + _state.effectiveDarkMinutes = newDarkTime + _currentSettings.sunset_offset; + } + else + { + _state.effectiveLightMinutes = _currentSettings.lightTime + _currentSettings.sunrise_offset; + _state.effectiveDarkMinutes = _currentSettings.darkTime + _currentSettings.sunset_offset; + } + } + else if (_currentSettings.scheduleMode == ScheduleMode::FixedHours) + { + _state.effectiveLightMinutes = _currentSettings.lightTime; + _state.effectiveDarkMinutes = _currentSettings.darkTime; + } + + // Handle manual override logic + if (_state.isManualOverride) + { + bool crossedBoundary = false; + if (_state.lastTickMinutes != -1) + { + int prev = _state.lastTickMinutes; + + // Handle midnight wraparound safely + if (now < prev) + { + crossedBoundary = + (prev <= _state.effectiveLightMinutes || now >= _state.effectiveLightMinutes) || + (prev <= _state.effectiveDarkMinutes || now >= _state.effectiveDarkMinutes); + } + else + { + crossedBoundary = + (prev < _state.effectiveLightMinutes && now >= _state.effectiveLightMinutes) || + (prev < _state.effectiveDarkMinutes && now >= _state.effectiveDarkMinutes); + } + } + + if (crossedBoundary) + { + _state.isManualOverride = false; + } + else + { + _state.lastTickMinutes = now; + return; + } + } + + _state.lastAppliedMode = _currentSettings.scheduleMode; + + bool shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + + bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight); + bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight); + + /* Logger::debug( + L"[LightSwitchStateManager] now = {:02d}:{:02d}, light boundary = {:02d}:{:02d} ({}), dark boundary = {:02d}:{:02d} ({})", + now / 60, + now % 60, + _state.effectiveLightMinutes / 60, + _state.effectiveLightMinutes % 60, + _state.effectiveLightMinutes, + _state.effectiveDarkMinutes / 60, + _state.effectiveDarkMinutes % 60, + _state.effectiveDarkMinutes); */ + + /* Logger::debug("should be light = {}, apps needs change = {}, system needs change = {}", + shouldBeLight ? "true" : "false", + appsNeedsToChange ? "true" : "false", + systemNeedsToChange ? "true" : "false"); */ + + // Only apply theme if there's a change or no override active + if (!_state.isManualOverride && (appsNeedsToChange || systemNeedsToChange)) + { + Logger::info(L"[LightSwitchStateManager] Applying {} theme", shouldBeLight ? L"light" : L"dark"); + ApplyTheme(shouldBeLight); + + _state.isSystemLightActive = GetCurrentSystemTheme(); + _state.isAppsLightActive = GetCurrentAppsTheme(); + } + + _state.lastTickMinutes = now; +} + + + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h new file mode 100644 index 0000000000..5c9bcc6e25 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -0,0 +1,47 @@ +#pragma once +#include "LightSwitchSettings.h" +#include + +// Represents runtime-only information (not saved in settings.json) +struct LightSwitchState +{ + ScheduleMode lastAppliedMode = ScheduleMode::Off; + bool isManualOverride = false; + bool isSystemLightActive = false; + bool isAppsLightActive = false; + int lastEvaluatedDay = -1; + int lastTickMinutes = -1; + + // Derived, runtime-resolved times + int effectiveLightMinutes = 0; // the boundary we actually act on + int effectiveDarkMinutes = 0; // includes offsets if needed +}; + +// The controller that reacts to settings changes, time ticks, and manual overrides. +class LightSwitchStateManager +{ +public: + LightSwitchStateManager(); + + // Called when settings.json changes or stabilizes. + void OnSettingsChanged(); + + // Called every minute (from service worker tick). + void OnTick(int currentMinutes); + + // Called when manual override is toggled (via shortcut or system change). + void OnManualOverride(); + + // Initial sync at startup to align internal state with system theme + void SyncInitialThemeState(); + + // Accessor for current state (optional, for debugging or telemetry) + const LightSwitchState& GetState() const { return _state; } + +private: + LightSwitchState _state; + std::mutex _stateMutex; + + void EvaluateAndApplyIfNeeded(); + bool CoordinatesAreValid(const std::wstring& lat, const std::wstring& lon); +}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchUtils.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchUtils.h new file mode 100644 index 0000000000..0f4462bb65 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchUtils.h @@ -0,0 +1,24 @@ +#pragma once +#include + +constexpr bool ShouldBeLight(int nowMinutes, int lightTime, int darkTime) +{ + // Normalize values into [0, 1439] + int normalizedLightTime = (lightTime % 1440 + 1440) % 1440; + int normalizedDarkTime = (darkTime % 1440 + 1440) % 1440; + int normalizedNowMinutes = (nowMinutes % 1440 + 1440) % 1440; + + // Case 1: Normal range, e.g. light mode comes before dark mode in the same day + if (normalizedLightTime < normalizedDarkTime) + return normalizedNowMinutes >= normalizedLightTime && normalizedNowMinutes < normalizedDarkTime; + + // Case 2: Wrap-around range, e.g. light mode starts in the evening and dark mode starts in the morning + return normalizedNowMinutes >= normalizedLightTime || normalizedNowMinutes < normalizedDarkTime; +} + +inline int GetNowMinutes() +{ + SYSTEMTIME st; + GetLocalTime(&st); + return st.wHour * 60 + st.wMinute; +} diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp new file mode 100644 index 0000000000..534e55f5e3 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp @@ -0,0 +1 @@ +#include "SettingsConstants.h" diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h new file mode 100644 index 0000000000..4872864eff --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h @@ -0,0 +1,14 @@ +#pragma once + +enum class SettingId +{ + ScheduleMode = 0, + Latitude, + Longitude, + LightTime, + DarkTime, + Sunrise_Offset, + Sunset_Offset, + ChangeSystem, + ChangeApps +}; \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h new file mode 100644 index 0000000000..b0ddde72ec --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include "SettingsConstants.h" +#include "LightSwitchSettings.h" + +class LightSwitchSettings; + +class SettingsObserver +{ +public: + SettingsObserver(std::unordered_set observedSettings) : + m_observedSettings(std::move(observedSettings)) + { + LightSwitchSettings::instance().AddObserver(*this); + } + + virtual ~SettingsObserver() + { + LightSwitchSettings::instance().RemoveObserver(*this); + } + + // Override this in your class to respond to updates + virtual void SettingsUpdate(SettingId type) {} + + virtual bool WantsToBeNotified(SettingId type) const noexcept + { + return m_observedSettings.contains(type); + } + +protected: + std::unordered_set m_observedSettings; +}; diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp new file mode 100644 index 0000000000..9633ab2fde --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp @@ -0,0 +1,111 @@ +#include +#include +#include +#include +#include "ThemeHelper.h" + +// Controls changing the themes. + +static void ResetColorPrevalence() +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = 0; // back to default value + RegSetValueEx(hKey, L"ColorPrevalence", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_DWMCOLORIZATIONCOLORCHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +void SetAppsTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"AppsUseLightTheme", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +void SetSystemTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"SystemUsesLightTheme", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + if (mode) // if are changing to light mode + { + ResetColorPrevalence(); + Logger::info(L"[LightSwitchService] Reset ColorPrevalence to default when switching to light mode."); + } + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +// Can think of this as "is the current theme light?" +bool GetCurrentSystemTheme() +{ + HKEY hKey; + DWORD value = 1; // default = light + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"SystemUsesLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} + +bool GetCurrentAppsTheme() +{ + HKEY hKey; + DWORD value = 1; + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"AppsUseLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h new file mode 100644 index 0000000000..5985fd95c8 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h @@ -0,0 +1,5 @@ +#pragma once +void SetSystemTheme(bool dark); +void SetAppsTheme(bool dark); +bool GetCurrentSystemTheme(); +bool GetCurrentAppsTheme(); diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp new file mode 100644 index 0000000000..7b07dd0ef7 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp @@ -0,0 +1,89 @@ +#include "ThemeScheduler.h" +#include + +SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day) +{ + double zenith = 90.833; + int N1 = static_cast(floor(275.0 * month / 9.0)); + int N2 = static_cast(floor((static_cast(month) + 9) / 12.0)); + int N3 = static_cast(floor((1.0 + floor((year - 4.0 * floor(year / 4.0) + 2.0) / 3.0)))); + int N = N1 - (N2 * N3) + day - 30; + + auto calcTime = [&](bool sunrise) -> double { + double lngHour = longitude / 15.0; + double t = sunrise ? N + ((6 - lngHour) / 24) : N + ((18 - lngHour) / 24); + + double M = (0.9856 * t) - 3.289; + double L = M + (1.916 * sin(deg2rad(M))) + (0.020 * sin(2 * deg2rad(M))) + 282.634; + if (L < 0) + L += 360; + if (L > 360) + L -= 360; + + double RA = rad2deg(atan(0.91764 * tan(deg2rad(L)))); + if (RA < 0) + RA += 360; + if (RA > 360) + RA -= 360; + + double Lquadrant = floor(L / 90) * 90; + double RAquadrant = floor(RA / 90) * 90; + RA = RA + (Lquadrant - RAquadrant); + RA /= 15; + + double sinDec = 0.39782 * sin(deg2rad(L)); + double cosDec = cos(asin(sinDec)); + + double cosH = (cos(deg2rad(zenith)) - (sinDec * sin(deg2rad(latitude)))) / (cosDec * cos(deg2rad(latitude))); + if (cosH > 1 || cosH < -1) + return -1; + + double H = sunrise ? 360 - rad2deg(acos(cosH)) : rad2deg(acos(cosH)); + H /= 15; + + double T = H + RA - (0.06571 * t) - 6.622; + double UT = T - lngHour; + while (UT < 0) + UT += 24; + while (UT >= 24) + UT -= 24; + + return UT; + }; + + double riseUT = calcTime(true); + double setUT = calcTime(false); + + auto toLocal = [](double UT) { + TIME_ZONE_INFORMATION tz; + DWORD state = GetTimeZoneInformation(&tz); + double totalBias = tz.Bias; + + if (state == TIME_ZONE_ID_DAYLIGHT) + totalBias += tz.DaylightBias; + else if (state == TIME_ZONE_ID_STANDARD) + totalBias += tz.StandardBias; + + double biasHours = -(totalBias / 60.0); + double localTime = UT + biasHours; + + while (localTime < 0) + localTime += 24; + while (localTime >= 24) + localTime -= 24; + + int hour = static_cast(localTime); + int minute = static_cast((localTime - hour) * 60); + return std::pair{ hour, minute }; + }; + + auto [riseHour, riseMinute] = toLocal(riseUT); + auto [setHour, setMinute] = toLocal(setUT); + + SunTimes result; + result.sunriseHour = riseHour; + result.sunriseMinute = riseMinute; + result.sunsetHour = setHour; + result.sunsetMinute = setMinute; + return result; +} diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h new file mode 100644 index 0000000000..4e6869830a --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include + +// Struct to hold calculated sunrise/sunset times +struct SunTimes +{ + int sunriseHour; + int sunriseMinute; + int sunsetHour; + int sunsetMinute; +}; + +constexpr double PI = 3.14159265358979323846; +constexpr double deg2rad(double deg) +{ + return deg * PI / 180.0; +} +constexpr double rad2deg(double rad) +{ + return rad * 180.0 / PI; +} + +SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day); diff --git a/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp new file mode 100644 index 0000000000..5e271fc8d0 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp @@ -0,0 +1,15 @@ + +#include "WinHookEventIDs.h" +#include +#include + +UINT WM_PRIV_SETTINGS_CHANGED = 0; + +std::once_flag init_flag; + +void InitializeWinhookEventIds() +{ + std::call_once(init_flag, [&] { + WM_PRIV_SETTINGS_CHANGED = RegisterWindowMessage(L"{11978F7B-221A-4E65-B9A9-693F7D6E4B25}"); + }); +} diff --git a/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h new file mode 100644 index 0000000000..177fd139cd --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h @@ -0,0 +1,6 @@ +#pragma once +#include + +extern UINT WM_PRIV_SETTINGS_CHANGED; // Scheduled when a watched settings file is updated + +void InitializeWinhookEventIds(); \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/packages.config b/src/modules/LightSwitch/LightSwitchService/packages.config new file mode 100644 index 0000000000..ff4b059648 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/installer/PowerToysSetupCustomActions/resource.h b/src/modules/LightSwitch/LightSwitchService/resource.h similarity index 53% rename from installer/PowerToysSetupCustomActions/resource.h rename to src/modules/LightSwitch/LightSwitchService/resource.h index d31a222438..e8ed3b4747 100644 --- a/installer/PowerToysSetupCustomActions/resource.h +++ b/src/modules/LightSwitch/LightSwitchService/resource.h @@ -1,10 +1,8 @@ //{{NO_DEPENDENCIES}} // Microsoft Visual C++ generated include file. -// Used by Resource.rc - -#define FILE_DESCRIPTION "PowerToys Setup Custom Actions" -#define INTERNAL_NAME "PowerToysSetupCustomActions" -#define ORIGINAL_FILENAME "PowerToysSetupCustomActions.dll" +// Used by LightSwitchService.rc +// +#define IDI_ICON1 101 // Next default values for new objects // @@ -13,8 +11,6 @@ #define _APS_NEXT_RESOURCE_VALUE 102 #define _APS_NEXT_COMMAND_VALUE 40001 #define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 102 +#define _APS_NEXT_SYMED_VALUE 101 #endif #endif - -#define IDR_BIN_MSIX_HELLO_PACKAGE 101 diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/LockScreenLogo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/LockScreenLogo.scale-200.png new file mode 100644 index 0000000000..7440f0d4bf Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/LockScreenLogo.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/SplashScreen.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000000..32f486a867 Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/SplashScreen.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square150x150Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square150x150Logo.scale-200.png new file mode 100644 index 0000000000..53ee3777ea Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square150x150Logo.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.scale-200.png new file mode 100644 index 0000000000..f713bba67f Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000000..dc9f5bea0c Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/StoreLogo.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/StoreLogo.png new file mode 100644 index 0000000000..a4586f26bd Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/StoreLogo.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Wide310x150Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000000..8b4a5d0dd5 Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Wide310x150Logo.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj b/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj new file mode 100644 index 0000000000..9770255af6 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj @@ -0,0 +1,22 @@ + + + + PowerToys.LightSwitch.UITests + LightSwitch.UITests + false + true + enable + Library + + + false + + + $(SolutionDir)$(Platform)\$(Configuration)\tests\LightSwitch.UITests\ + + + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest new file mode 100644 index 0000000000..a38ad92615 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest @@ -0,0 +1,51 @@ + + + + + + + + + + LightSwitch.UITests + Microsoft + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.cs new file mode 100644 index 0000000000..aaa5124995 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestGeolocation : UITestBase + { + public TestGeolocation() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.Geolocation")] + [TestCategory("Location")] + public void TestGeolocationUpdate() + { + TestHelper.InitializeTest(this, "geolocation test"); + TestHelper.PerformGeolocationTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs new file mode 100644 index 0000000000..37041b4b2d --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs @@ -0,0 +1,445 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Win32; + +namespace LightSwitch.UITests +{ + internal sealed class TestHelper + { + private static readonly string[] ShortcutSeparators = { " + ", "+", " " }; + + /// + /// Performs common test initialization: navigate to settings, enable toggle, verify shortcut + /// + /// The test base instance + /// Name of the test for assertions + /// The activation keys for the test + public static Key[] InitializeTest(UITestBase testBase, string testName) + { + LaunchFromSetting(testBase); + + var toggleSwitch = SetLightSwitchToggle(testBase, enable: true); + Assert.IsTrue( + toggleSwitch.IsOn, + $"Light Switch toggle switch should be ON for {testName}"); + + var activationKeys = ReadActivationShortcut(testBase); + Assert.IsNotNull(activationKeys, "Should be able to read activation shortcut"); + Assert.IsTrue(activationKeys.Length > 0, "Activation shortcut should contain at least one key"); + + return activationKeys; + } + + /// + /// Navigate to the Light Switch settings page + /// + public static void LaunchFromSetting(UITestBase testBase) + { + var lightSwitch = testBase.Session.FindAll(By.AccessibilityId("LightSwitchNavItem")); + + if (lightSwitch.Count == 0) + { + testBase.Session.Find(By.AccessibilityId("SystemToolsNavItem"), 5000).Click(msPostAction: 500); + } + + testBase.Session.Find(By.AccessibilityId("LightSwitchNavItem"), 5000).Click(msPostAction: 500); + } + + /// + /// Set the Light Switch enable toggle switch to the specified state + /// + public static ToggleSwitch SetLightSwitchToggle(UITestBase testBase, bool enable) + { + var toggleSwitch = testBase.Session.Find(By.AccessibilityId("Toggle_LightSwitch"), 5000); + + if (toggleSwitch.IsOn != enable) + { + toggleSwitch.Click(msPreAction: 1000, msPostAction: 2000); + } + + if (toggleSwitch.IsOn != enable) + { + testBase.Session.SendKey(Key.Space, msPreAction: 0, msPostAction: 2000); + } + + return toggleSwitch; + } + + /// + /// Read the current activation shortcut from the ShortcutControl + /// + public static Key[] ReadActivationShortcut(UITestBase testBase) + { + var shortcutCard = testBase.Session.Find(By.AccessibilityId("Shortcut_LightSwitch"), 5000); + var shortcutButton = shortcutCard.Find(By.AccessibilityId("EditButton"), 5000); + return ParseShortcutText(shortcutButton.HelpText); + } + + /// + /// Parse shortcut text like "Win + Ctrl + Shift + M" into Key array + /// + private static Key[] ParseShortcutText(string shortcutText) + { + if (string.IsNullOrEmpty(shortcutText)) + { + return new Key[] { Key.Win, Key.Ctrl, Key.Shift, Key.D }; + } + + var keys = new List(); + var parts = shortcutText.Split(ShortcutSeparators, StringSplitOptions.RemoveEmptyEntries); + + foreach (var part in parts) + { + var cleanPart = part.Trim().ToLowerInvariant(); + var key = cleanPart switch + { + "win" or "windows" => Key.Win, + "ctrl" or "control" => Key.Ctrl, + "shift" => Key.Shift, + "alt" => Key.Alt, + _ when cleanPart.Length == 1 && char.IsLetter(cleanPart[0]) && + cleanPart[0] >= 'a' && cleanPart[0] <= 'z' => + (Key)Enum.Parse(typeof(Key), cleanPart.ToUpperInvariant()), + _ => (Key?)null, + }; + + if (key.HasValue) + { + keys.Add(key.Value); + } + } + + return keys.Count > 0 ? keys.ToArray() : new Key[] { Key.Win, Key.Ctrl, Key.Shift, Key.D }; + } + + /// + /// Performs common test cleanup: close LightSwitch task + /// + /// The test base instance + public static void CleanupTest(UITestBase testBase) + { + // TODO: Make sure the task kills? + // CloseLightSwitch(testBase); + + // Ensure we're attached to settings after cleanup + try + { + testBase.Session.Attach(PowerToysModule.PowerToysSettings); + } + catch + { + // Ignore attachment errors - this is just cleanup + } + } + + /// + /// Perform a update time test operation + /// + public static void PerformUpdateTimeTest(UITestBase testBase) + { + // Make sure in manual mode + var modeCombobox = testBase.Session.Find(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + var neededTabs = 6; + + if (modeCombobox.Text != "Fixed hours") + { + modeCombobox.Click(); + var manualListItem = testBase.Session.Find(By.AccessibilityId("ManualCBItem_LightSwitch"), 5000); + Assert.IsNotNull(manualListItem, "Fixed Hours combobox item not found."); + manualListItem.Click(); + neededTabs = 1; + } + + Assert.AreEqual("Fixed hours", modeCombobox.Text, "Mode combobox should be set to Fixed hours."); + + var timeline = testBase.Session.Find(By.AccessibilityId("Timeline_LightSwitch"), 5000); + Assert.IsNotNull(timeline, "Timeline not found."); + + var helpText = timeline.GetAttribute("HelpText"); + string originalEndValue = GetHelpTextValue(helpText, "End"); + + for (int i = 0; i < neededTabs; i++) + { + testBase.Session.SendKeys(Key.Tab); + } + + testBase.Session.SendKeys(Key.Enter); + testBase.Session.SendKeys(Key.Up); + testBase.Session.SendKeys(Key.Enter); + + helpText = timeline.GetAttribute("HelpText"); + string updatedEndValue = GetHelpTextValue(helpText, "End"); + + Assert.AreNotEqual(originalEndValue, updatedEndValue, "Timeline end time should have been updated."); + + helpText = timeline.GetAttribute("HelpText"); + string originalStartValue = GetHelpTextValue(helpText, "Start"); + + testBase.Session.SendKeys(Key.Tab); + testBase.Session.SendKeys(Key.Enter); + testBase.Session.SendKeys(Key.Up); + testBase.Session.SendKeys(Key.Enter); + + helpText = timeline.GetAttribute("HelpText"); + string updatedStartValue = GetHelpTextValue(helpText, "Start"); + + Assert.AreNotEqual(originalStartValue, updatedStartValue, "Timeline start time should have been updated."); + } + + /// + /// Perform a update manual location test operation + /// + public static void PerformUserSelectedLocationTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + // Click the select location button + var setLocationButton = testBase.Session.Find(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(setLocationButton, "Set location button not found."); + setLocationButton.Click(msPostAction: 1000); + + var latitudeBox = testBase.Session.Find(By.AccessibilityId("LatitudeBox_LightSwitch"), 5000); + Assert.IsNotNull(latitudeBox, "Latitude text box not found."); + latitudeBox.Click(); + + testBase.Session.SendKeys(Key.Up); + + var longitudeBox = testBase.Session.Find(By.AccessibilityId("LongitudeBox_LightSwitch"), 5000); + Assert.IsNotNull(longitudeBox, "Longitude text box not found."); + longitudeBox.Click(); + + testBase.Session.SendKeys(Key.Down); + + var sunrise = testBase.Session.Find(By.AccessibilityId("SunriseText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunrise.Text)); + + var sunset = testBase.Session.Find(By.AccessibilityId("SunsetText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunset.Text)); + } + + /// + /// Perform a update geolocation test operation + /// + public static void PerformGeolocationTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + // Click the select location button + var setLocationButton = testBase.Session.Find(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(setLocationButton, "Set location button not found."); + setLocationButton.Click(msPostAction: 1000); + + var syncLocationButton = testBase.Session.Find(By.AccessibilityId("SyncLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(syncLocationButton, "Sync location button not found."); + syncLocationButton.Click(msPostAction: 8000); + + var sunrise = testBase.Session.Find(By.AccessibilityId("SunriseText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunrise.Text)); + + var sunset = testBase.Session.Find(By.AccessibilityId("SunsetText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunset.Text)); + } + + /// + /// Perform a update time test operation + /// + public static void PerformOffsetTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + // Testing sunrise offset + var sunriseOffset = testBase.Session.Find(By.AccessibilityId("SunriseOffset_LightSwitch"), 5000); + Assert.IsNotNull(sunriseOffset, "Sunrise offset number box not found."); + + var timeline = testBase.Session.Find(By.AccessibilityId("Timeline_LightSwitch"), 5000); + Assert.IsNotNull(timeline, "Timeline not found."); + + var helpText = timeline.GetAttribute("HelpText"); + string originalStartValue = GetHelpTextValue(helpText, "Start"); + + sunriseOffset.Click(); + testBase.Session.SendKeys(Key.Up); + + helpText = timeline.GetAttribute("HelpText"); + string updatedStartValue = GetHelpTextValue(helpText, "Start"); + + Assert.AreNotEqual(originalStartValue, updatedStartValue, "Timeline start time should have been updated."); + + // Testing sunset offset + var sunsetOffset = testBase.Session.Find(By.AccessibilityId("SunsetOffset_LightSwitch"), 5000); + Assert.IsNotNull(sunsetOffset, "Sunrise offset number box not found."); + + helpText = timeline.GetAttribute("HelpText"); + string originalEndValue = GetHelpTextValue(helpText, "End"); + + sunsetOffset.Click(); + testBase.Session.SendKeys(Key.Up); + + helpText = timeline.GetAttribute("HelpText"); + string updatedEndValue = GetHelpTextValue(helpText, "End"); + + Assert.AreNotEqual(originalEndValue, updatedEndValue, "Timeline end time should have been updated."); + } + + /// + /// Perform a test for shortcut changing themes + /// + public static void PerformShortcutTest(UITestBase testBase, Key[] activationKeys) + { + // Test when both are checked + var systemCheckbox = testBase.Session.Find(By.AccessibilityId("ChangeSystemCheckbox_LightSwitch"), 5000); + Assert.IsNotNull(systemCheckbox, "System checkbox not found."); + + var scrollViewer = testBase.Session.Find(By.AccessibilityId("PageScrollViewer")); + systemCheckbox.EnsureVisible(scrollViewer); + + int neededTabs = 10; + + if (!systemCheckbox.Selected) + { + for (int i = 0; i < neededTabs; i++) + { + testBase.Session.SendKeys(Key.Tab); + } + + systemCheckbox.Click(); + } + + Assert.IsTrue(systemCheckbox.Selected, "System checkbox should be checked."); + + var appsCheckbox = testBase.Session.Find(By.AccessibilityId("ChangeAppsCheckbox_LightSwitch"), 5000); + Assert.IsNotNull(appsCheckbox, "Apps checkbox not found."); + + if (!appsCheckbox.Selected) + { + appsCheckbox.Click(); + } + + Assert.IsTrue(appsCheckbox.Selected, "Apps checkbox should be checked."); + + var systemBeforeValue = GetSystemTheme(); + var appsBeforeValue = GetAppsTheme(); + + Task.Delay(1000).Wait(); + testBase.Session.SendKeys(activationKeys); + Task.Delay(5000).Wait(); + + var systemAfterValue = GetSystemTheme(); + var appsAfterValue = GetAppsTheme(); + + Assert.AreNotEqual(systemBeforeValue, systemAfterValue, "System theme should have changed."); + Assert.AreNotEqual(appsBeforeValue, appsAfterValue, "Apps theme should have changed."); + + // Test with nothing checked + if (systemCheckbox.Selected) + { + systemCheckbox.Click(); + } + + if (appsCheckbox.Selected) + { + appsCheckbox.Click(); + } + + Assert.IsFalse(systemCheckbox.Selected, "System checkbox should be unchecked."); + Assert.IsFalse(appsCheckbox.Selected, "Apps checkbox should be unchecked."); + + var noneSystemBeforeValue = GetSystemTheme(); + var noneAppsBeforeValue = GetAppsTheme(); + + Task.Delay(1000).Wait(); + testBase.Session.SendKeys(activationKeys); + Task.Delay(5000).Wait(); + + var noneSystemAfterValue = GetSystemTheme(); + var noneAppsAfterValue = GetAppsTheme(); + + Assert.AreEqual(noneSystemBeforeValue, noneSystemAfterValue, "System theme should not have changed."); + Assert.AreEqual(noneAppsBeforeValue, noneAppsAfterValue, "Apps theme should not have changed."); + } + + /* Helpers */ + private static int GetSystemTheme() + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + if (key is null) + { + return 1; + } + + return (int)key.GetValue("SystemUsesLightTheme", 1); + } + + private static int GetAppsTheme() + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + if (key is null) + { + return 1; + } + + return (int)key.GetValue("AppsUseLightTheme", 1); + } + + private static string GetHelpTextValue(string helpText, string key) + { + foreach (var part in helpText.Split(';')) + { + var kv = part.Split('='); + if (kv.Length == 2 && kv[0] == key) + { + return kv[1]; + } + } + + return string.Empty; + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.cs new file mode 100644 index 0000000000..e8ed9debf6 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestOffset : UITestBase + { + public TestOffset() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.Offset")] + [TestCategory("Time")] + public void TestTimeOffset() + { + TestHelper.InitializeTest(this, "offset test"); + TestHelper.PerformOffsetTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.cs new file mode 100644 index 0000000000..26e17c4612 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestShortcut : UITestBase + { + public TestShortcut() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.TestShortcut")] + [TestCategory("Shortcut")] + public void TestLightSwitchShortcut() + { + var activationKeys = TestHelper.InitializeTest(this, "light switch shortcut test"); + TestHelper.PerformShortcutTest(this, activationKeys); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.cs new file mode 100644 index 0000000000..f92909657f --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestUpdateManualTime : UITestBase + { + public TestUpdateManualTime() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.UpdateManualTime")] + [TestCategory("Time")] + public void TestUpdateTime() + { + TestHelper.InitializeTest(this, "update manual time test"); + TestHelper.PerformUpdateTimeTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.cs new file mode 100644 index 0000000000..924a04d9d9 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestUserSelectedLocation : UITestBase + { + public TestUserSelectedLocation() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.UserSelectedLocation")] + [TestCategory("Location")] + public void TestUserSelectedLocationUpdate() + { + TestHelper.InitializeTest(this, "user selected location test"); + TestHelper.PerformUserSelectedLocationTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest b/src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest new file mode 100644 index 0000000000..0cec0ecb5e --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + PerMonitorV2 + + + \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj b/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj index 28b1dd8db7..6de7c50b55 100644 --- a/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj +++ b/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj @@ -1,6 +1,12 @@  - + + + + + + + @@ -141,7 +147,13 @@ - + + + + + + + @@ -153,7 +165,19 @@ - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolCore/packages.config b/src/modules/MeasureTool/MeasureToolCore/packages.config index 9e58941250..6416ca5b16 100644 --- a/src/modules/MeasureTool/MeasureToolCore/packages.config +++ b/src/modules/MeasureTool/MeasureToolCore/packages.config @@ -4,5 +4,14 @@ - + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.rc b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc new file mode 100644 index 0000000000..37752edae0 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc @@ -0,0 +1,46 @@ +#include +#include "resource.h" +#include "../../../../common/version/version.h" + +#define APSTUDIO_READONLY_SYMBOLS +#include "winres.h" +#undef APSTUDIO_READONLY_SYMBOLS + +1 VERSIONINFO + FILEVERSION FILE_VERSION + PRODUCTVERSION PRODUCT_VERSION + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS_NT_WINDOWS32 + FILETYPE VFT_DLL + FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", "PowerToys CursorWrap" + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", "CursorWrap" + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", "PowerToys.CursorWrap.dll" + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +STRINGTABLE +BEGIN + IDS_CURSORWRAP_NAME L"CursorWrap" + IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG L"Disable wrapping during drag" +END \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj new file mode 100644 index 0000000000..59e2095ca7 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj @@ -0,0 +1,130 @@ + + + + + 15.0 + {48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5} + Win32Proj + CursorWrap + CursorWrap + + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\ + PowerToys.CursorWrap + + + true + + + false + + + + Level3 + Disabled + true + _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreadedDebug + stdcpplatest + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Level3 + MaxSpeed + true + true + true + NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreaded + stdcpplatest + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + + + + + + + + + + + Create + + + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h new file mode 100644 index 0000000000..4274ad714f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h @@ -0,0 +1,213 @@ +#pragma once + +#include +#include + +// Test case structure for comprehensive monitor layout testing +struct MonitorTestCase +{ + std::string name; + std::string description; + int grid[3][3]; // 3x3 grid representing monitor layout (0 = no monitor, 1-9 = monitor ID) + + // Test scenarios to validate + struct TestScenario + { + int sourceMonitor; // Which monitor to start cursor on (1-based) + int edgeDirection; // 0=top, 1=right, 2=bottom, 3=left + int expectedTargetMonitor; // Expected destination monitor (1-based, -1 = wrap within same monitor) + std::string description; + }; + + std::vector scenarios; +}; + +// Comprehensive test cases for all possible 3x3 monitor grid configurations +class CursorWrapTestSuite +{ +public: + static std::vector GetAllTestCases() + { + std::vector testCases; + + // Test Case 1: Single monitor (center) + testCases.push_back({ + "Single_Center", + "Single monitor in center position", + { + {0, 0, 0}, + {0, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, -1, "Top edge wraps to bottom of same monitor"}, + {1, 1, -1, "Right edge wraps to left of same monitor"}, + {1, 2, -1, "Bottom edge wraps to top of same monitor"}, + {1, 3, -1, "Left edge wraps to right of same monitor"} + } + }); + + // Test Case 2: Two monitors horizontal (left + right) + testCases.push_back({ + "Dual_Horizontal_Left_Right", + "Two monitors: left + right", + { + {0, 0, 0}, + {1, 0, 2}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom of monitor 1"}, + {1, 1, 2, "Monitor 1 right edge moves to monitor 2 left"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left edge wraps to right of monitor 1"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right edge wraps to left of monitor 2"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, 1, "Monitor 2 left edge moves to monitor 1 right"} + } + }); + + // Test Case 3: Two monitors vertical (Monitor 2 above Monitor 1) - CORRECTED FOR USER'S SETUP + testCases.push_back({ + "Dual_Vertical_2_Above_1", + "Two monitors: Monitor 2 (top) above Monitor 1 (bottom/main)", + { + {0, 2, 0}, // Row 0: Monitor 2 (physically top monitor) + {0, 0, 0}, // Row 1: Empty + {0, 1, 0} // Row 2: Monitor 1 (physically bottom/main monitor) + }, + { + // Monitor 1 (bottom/main monitor) tests + {1, 0, 2, "Monitor 1 (bottom) top edge should move to Monitor 2 (top) bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left wraps to right of monitor 1"}, + + // Monitor 2 (top monitor) tests + {2, 0, -1, "Monitor 2 (top) top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right wraps to left of monitor 2"}, + {2, 2, 1, "Monitor 2 (top) bottom edge should move to Monitor 1 (bottom) top"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"} + } + }); + + // Test Case 4: Three monitors L-shape (center + left + top) + testCases.push_back({ + "Triple_L_Shape", + "Three monitors in L-shape: center + left + top", + { + {0, 3, 0}, + {2, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, 3, "Monitor 1 top moves to monitor 3 bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, 2, "Monitor 1 left moves to monitor 2 right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, 1, "Monitor 2 right moves to monitor 1 left"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"}, + {3, 0, -1, "Monitor 3 top wraps to bottom of monitor 3"}, + {3, 1, -1, "Monitor 3 right wraps to left of monitor 3"}, + {3, 2, 1, "Monitor 3 bottom moves to monitor 1 top"}, + {3, 3, -1, "Monitor 3 left wraps to right of monitor 3"} + } + }); + + // Test Case 5: Three monitors horizontal (left + center + right) + testCases.push_back({ + "Triple_Horizontal", + "Three monitors horizontal: left + center + right", + { + {0, 0, 0}, + {1, 2, 3}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, 2, "Monitor 1 right moves to monitor 2"}, + {1, 2, -1, "Monitor 1 bottom wraps to top"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom"}, + {2, 1, 3, "Monitor 2 right moves to monitor 3"}, + {2, 2, -1, "Monitor 2 bottom wraps to top"}, + {2, 3, 1, "Monitor 2 left moves to monitor 1"}, + {3, 0, -1, "Monitor 3 top wraps to bottom"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, 2, "Monitor 3 left moves to monitor 2"} + } + }); + + // Test Case 6: Three monitors vertical (top + center + bottom) + testCases.push_back({ + "Triple_Vertical", + "Three monitors vertical: top + center + bottom", + { + {0, 1, 0}, + {0, 2, 0}, + {0, 3, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left"}, + {1, 2, 2, "Monitor 1 bottom moves to monitor 2"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, 1, "Monitor 2 top moves to monitor 1"}, + {2, 1, -1, "Monitor 2 right wraps to left"}, + {2, 2, 3, "Monitor 2 bottom moves to monitor 3"}, + {2, 3, -1, "Monitor 2 left wraps to right"}, + {3, 0, 2, "Monitor 3 top moves to monitor 2"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, -1, "Monitor 3 left wraps to right"} + } + }); + + return testCases; + } + + // Helper function to print test case in a readable format + static std::string FormatTestCase(const MonitorTestCase& testCase) + { + std::string result = "Test Case: " + testCase.name + "\n"; + result += "Description: " + testCase.description + "\n"; + result += "Layout:\n"; + + for (int row = 0; row < 3; row++) + { + result += " "; + for (int col = 0; col < 3; col++) + { + if (testCase.grid[row][col] == 0) + { + result += ". "; + } + else + { + result += std::to_string(testCase.grid[row][col]) + " "; + } + } + result += "\n"; + } + + result += "Test Scenarios:\n"; + for (const auto& scenario : testCase.scenarios) + { + result += " - " + scenario.description + "\n"; + } + + return result; + } + + // Helper function to validate a specific test case against actual behavior + static bool ValidateTestCase(const MonitorTestCase& testCase) + { + // This would be called with actual CursorWrap instance to validate behavior + // For now, just return true - this would need actual implementation + return true; + } +}; \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/dllmain.cpp b/src/modules/MouseUtils/CursorWrap/dllmain.cpp new file mode 100644 index 0000000000..74524ed9f9 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -0,0 +1,1046 @@ +#include "pch.h" +#include "../../../interface/powertoy_module_interface.h" +#include "../../../common/SettingsAPI/settings_objects.h" +#include "trace.h" +#include "../../../common/utils/process_path.h" +#include "../../../common/utils/resources.h" +#include "../../../common/logger/logger.h" +#include "../../../common/utils/logger_helper.h" +#include +#include +#include +#include +#include +#include +#include +#include "resource.h" +#include "CursorWrapTests.h" + +// Disable C26451 arithmetic overflow warning for this file since the operations are safe in this context +#pragma warning(disable: 26451) + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +// Non-Localizable strings +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_VALUE[] = L"value"; + const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"activation_shortcut"; + const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; + const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag"; +} + +// The PowerToy name that will be shown in the settings. +const static wchar_t* MODULE_NAME = L"CursorWrap"; +// Add a description that will we shown in the module settings page. +const static wchar_t* MODULE_DESC = L""; + +// Mouse hook data structure +struct MonitorInfo +{ + RECT rect; + bool isPrimary; + int monitorId; // Add monitor ID for easier debugging +}; + +// Add structure for logical monitor grid position +struct LogicalPosition +{ + int row; + int col; + bool isValid; +}; + +// Add monitor topology helper +struct MonitorTopology +{ + std::vector> grid; // 3x3 grid of monitors + std::map monitorToPosition; + std::map, HMONITOR> positionToMonitor; + + void Initialize(const std::vector& monitors); + LogicalPosition GetPosition(HMONITOR monitor) const; + HMONITOR GetMonitorAt(int row, int col) const; + HMONITOR FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const; +}; + +// Forward declaration +class CursorWrap; + +// Global instance pointer for the mouse hook +static CursorWrap* g_cursorWrapInstance = nullptr; + +// Implement the PowerToy Module Interface and all the required methods. +class CursorWrap : public PowertoyModuleIface +{ +private: + // The PowerToy state. + bool m_enabled = false; + bool m_autoActivate = false; + bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag + + // Mouse hook + HHOOK m_mouseHook = nullptr; + std::atomic m_hookActive{ false }; + + // Monitor information + std::vector m_monitors; + MonitorTopology m_topology; + + // Hotkey + Hotkey m_activationHotkey{}; + +public: + // Constructor + CursorWrap() + { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::cursorWrapLoggerName); + init_settings(); + UpdateMonitorInfo(); + g_cursorWrapInstance = this; // Set global instance pointer + }; + + // Destroy the powertoy and free memory + virtual void destroy() override + { + StopMouseHook(); + g_cursorWrapInstance = nullptr; // Clear global instance pointer + delete this; + } + + // Return the localized display name of the powertoy + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + // Return the non localized key of the powertoy, this will be cached by the runner + virtual const wchar_t* get_key() override + { + return MODULE_NAME; + } + + // Return the configured status for the gpo policy for the module + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredCursorWrapEnabledValue(); + } + + // Return JSON with the configuration options. + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + + PowerToysSettings::Settings settings(hinstance, get_name()); + + settings.set_description(IDS_CURSORWRAP_NAME); + settings.set_icon_key(L"pt-cursor-wrap"); + + // Create HotkeyObject from the Hotkey struct for the settings + auto hotkey_object = PowerToysSettings::HotkeyObject::from_settings( + m_activationHotkey.win, + m_activationHotkey.ctrl, + m_activationHotkey.alt, + m_activationHotkey.shift, + m_activationHotkey.key); + + settings.add_hotkey(JSON_KEY_ACTIVATION_SHORTCUT, IDS_CURSORWRAP_NAME, hotkey_object); + settings.add_bool_toggle(JSON_KEY_AUTO_ACTIVATE, IDS_CURSORWRAP_NAME, m_autoActivate); + settings.add_bool_toggle(JSON_KEY_DISABLE_WRAP_DURING_DRAG, IDS_CURSORWRAP_NAME, m_disableWrapDuringDrag); + + return settings.serialize_to_buffer(buffer, buffer_size); + } + + // Signal from the Settings editor to call a custom action. + // This can be used to spawn more complex editors. + virtual void call_custom_action(const wchar_t* /*action*/) override {} + + // Called by the runner to pass the updated settings values as a serialized JSON. + virtual void set_config(const wchar_t* config) override + { + try + { + // Parse the input JSON string. + PowerToysSettings::PowerToyValues values = + PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + + parse_settings(values); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to parse CursorWrap settings json."); + } + } + + // Enable the powertoy + virtual void enable() + { + m_enabled = true; + Trace::EnableCursorWrap(true); + + // Always start the mouse hook when the module is enabled + // This ensures cursor wrapping is active immediately after enabling + StartMouseHook(); + Logger::info("CursorWrap enabled - mouse hook started"); + } + + // Disable the powertoy + virtual void disable() + { + m_enabled = false; + Trace::EnableCursorWrap(false); + StopMouseHook(); + Logger::info("CursorWrap disabled - mouse hook stopped"); + } + + // Returns if the powertoys is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + // Returns whether the PowerToys should be enabled by default + virtual bool is_enabled_by_default() const override + { + return false; + } + + // Legacy hotkey support + virtual size_t get_hotkeys(Hotkey* buffer, size_t buffer_size) override + { + if (buffer && buffer_size >= 1) + { + buffer[0] = m_activationHotkey; + } + return 1; + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (!m_enabled || hotkeyId != 0) + { + return false; + } + + // Toggle cursor wrapping + if (m_hookActive) + { + StopMouseHook(); + } + else + { + StartMouseHook(); +#ifdef _DEBUG + // Run comprehensive tests when hook is started in debug builds + RunComprehensiveTests(); +#endif + } + + return true; + } + +private: + // Load the settings file. + void init_settings() + { + try + { + // Load and parse the settings file for this PowerToy. + PowerToysSettings::PowerToyValues settings = + PowerToysSettings::PowerToyValues::load_from_settings_file(CursorWrap::get_key()); + parse_settings(settings); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to load the CursorWrap settings json from file."); + } + } + + void parse_settings(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + // Parse activation HotKey + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); + + m_activationHotkey.win = hotkey.win_pressed(); + m_activationHotkey.ctrl = hotkey.ctrl_pressed(); + m_activationHotkey.shift = hotkey.shift_pressed(); + m_activationHotkey.alt = hotkey.alt_pressed(); + m_activationHotkey.key = static_cast(hotkey.get_code()); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap activation shortcut"); + } + + try + { + // Parse auto activate + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE); + m_autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap auto activate from settings. Will use default value"); + } + + try + { + // Parse disable wrap during drag + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_DISABLE_WRAP_DURING_DRAG)) + { + auto disableDragObject = propertiesObject.GetNamedObject(JSON_KEY_DISABLE_WRAP_DURING_DRAG); + m_disableWrapDuringDrag = disableDragObject.GetNamedBoolean(JSON_KEY_VALUE); + } + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap disable wrap during drag from settings. Will use default value (true)"); + } + } + else + { + Logger::info("CursorWrap settings are empty"); + } + + // Set default hotkey if not configured + if (m_activationHotkey.key == 0) + { + m_activationHotkey.win = true; + m_activationHotkey.alt = true; + m_activationHotkey.ctrl = false; + m_activationHotkey.shift = false; + m_activationHotkey.key = 'U'; // Win+Alt+U + } + } + + void UpdateMonitorInfo() + { + m_monitors.clear(); + + EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) -> BOOL { + auto* self = reinterpret_cast(lParam); + + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(hMonitor, &mi)) + { + MonitorInfo info{}; + info.rect = mi.rcMonitor; + info.isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0; + info.monitorId = static_cast(self->m_monitors.size()); + self->m_monitors.push_back(info); + } + + return TRUE; + }, reinterpret_cast(this)); + + // Initialize monitor topology + m_topology.Initialize(m_monitors); + } + + void StartMouseHook() + { + if (m_mouseHook || m_hookActive) + { + Logger::info("CursorWrap mouse hook already active"); + return; + } + + UpdateMonitorInfo(); + + m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, GetModuleHandle(nullptr), 0); + if (m_mouseHook) + { + m_hookActive = true; + Logger::info("CursorWrap mouse hook started successfully"); +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Hook installed"); +#endif + } + else + { + DWORD error = GetLastError(); + Logger::error(L"Failed to install CursorWrap mouse hook, error: {}", error); + } + } + + void StopMouseHook() + { + if (m_mouseHook) + { + UnhookWindowsHookEx(m_mouseHook); + m_mouseHook = nullptr; + m_hookActive = false; + Logger::info("CursorWrap mouse hook stopped"); +#ifdef _DEBUG + Logger::info("CursorWrap DEBUG: Mouse hook stopped"); +#endif + } + } + + static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) + { + if (nCode >= 0 && wParam == WM_MOUSEMOVE) + { + auto* pMouseStruct = reinterpret_cast(lParam); + POINT currentPos = { pMouseStruct->pt.x, pMouseStruct->pt.y }; + + if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive) + { + POINT newPos = g_cursorWrapInstance->HandleMouseMove(currentPos); + if (newPos.x != currentPos.x || newPos.y != currentPos.y) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Wrapping cursor from ({}, {}) to ({}, {})", + currentPos.x, currentPos.y, newPos.x, newPos.y); +#endif + SetCursorPos(newPos.x, newPos.y); + return 1; // Suppress the original message + } + } + } + + return CallNextHookEx(nullptr, nCode, wParam, lParam); + } + + // *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC *** + // Implements vertical scrolling to bottom/top of vertical stack as requested + POINT HandleMouseMove(const POINT& currentPos) + { + POINT newPos = currentPos; + + // Check if we should skip wrapping during drag if the setting is enabled + if (m_disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000)) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Left mouse button is down and disable_wrap_during_drag is enabled - skipping wrap"); +#endif + return currentPos; // Return unchanged position (no wrapping) + } + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE START ======="); + Logger::info(L"CursorWrap DEBUG: Input position ({}, {})", currentPos.x, currentPos.y); +#endif + + // Find which monitor the cursor is currently on + HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); + MONITORINFO currentMonitorInfo{}; + currentMonitorInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(currentMonitor, ¤tMonitorInfo); + + LogicalPosition currentLogicalPos = m_topology.GetPosition(currentMonitor); + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Current monitor bounds: Left={}, Top={}, Right={}, Bottom={}", + currentMonitorInfo.rcMonitor.left, currentMonitorInfo.rcMonitor.top, + currentMonitorInfo.rcMonitor.right, currentMonitorInfo.rcMonitor.bottom); + Logger::info(L"CursorWrap DEBUG: Logical position: Row={}, Col={}, Valid={}", + currentLogicalPos.row, currentLogicalPos.col, currentLogicalPos.isValid); +#endif + + bool wrapped = false; + + // *** VERTICAL WRAPPING LOGIC - CONFIRMED WORKING *** + // Move to bottom of vertical stack when hitting top edge + if (currentPos.y <= currentMonitorInfo.rcMonitor.top) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED ======="); +#endif + + // Find the bottom-most monitor in the vertical stack (same column) + HMONITOR bottomMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search down from current position to find the bottom-most monitor in same column + for (int row = 2; row >= 0; row--) { // Start from bottom and work up + HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); + if (candidateMonitor) { + bottomMonitor = candidateMonitor; + break; // Found the bottom-most monitor + } + } + } + + if (bottomMonitor && bottomMonitor != currentMonitor) { + // *** MOVE TO BOTTOM OF VERTICAL STACK *** + MONITORINFO bottomInfo{}; + bottomInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(bottomMonitor, &bottomInfo); + + // Calculate relative X position to maintain cursor X alignment + double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / + (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); + + int targetWidth = bottomInfo.rcMonitor.right - bottomInfo.rcMonitor.left; + newPos.x = bottomInfo.rcMonitor.left + static_cast(relativeX * targetWidth); + newPos.y = bottomInfo.rcMonitor.bottom - 1; // Bottom edge of bottom monitor + + // Clamp X to target monitor bounds + newPos.x = max(bottomInfo.rcMonitor.left, min(newPos.x, bottomInfo.rcMonitor.right - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to bottom of vertical stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.y = currentMonitorInfo.rcMonitor.bottom - 1; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + else if (currentPos.y >= currentMonitorInfo.rcMonitor.bottom - 1) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: BOTTOM EDGE DETECTED ======="); +#endif + + // Find the top-most monitor in the vertical stack (same column) + HMONITOR topMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search up from current position to find the top-most monitor in same column + for (int row = 0; row <= 2; row++) { // Start from top and work down + HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); + if (candidateMonitor) { + topMonitor = candidateMonitor; + break; // Found the top-most monitor + } + } + } + + if (topMonitor && topMonitor != currentMonitor) { + // *** MOVE TO TOP OF VERTICAL STACK *** + MONITORINFO topInfo{}; + topInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(topMonitor, &topInfo); + + // Calculate relative X position to maintain cursor X alignment + double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / + (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); + + int targetWidth = topInfo.rcMonitor.right - topInfo.rcMonitor.left; + newPos.x = topInfo.rcMonitor.left + static_cast(relativeX * targetWidth); + newPos.y = topInfo.rcMonitor.top; // Top edge of top monitor + + // Clamp X to target monitor bounds + newPos.x = max(topInfo.rcMonitor.left, min(newPos.x, topInfo.rcMonitor.right - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to top of vertical stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.y = currentMonitorInfo.rcMonitor.top; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + + // *** FIXED HORIZONTAL WRAPPING LOGIC *** + // Move to opposite end of horizontal stack when hitting left/right edge + // Only handle horizontal wrapping if we haven't already wrapped vertically + if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED ======="); +#endif + + // Find the right-most monitor in the horizontal stack (same row) + HMONITOR rightMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search right from current position to find the right-most monitor in same row + for (int col = 2; col >= 0; col--) { // Start from right and work left + HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); + if (candidateMonitor) { + rightMonitor = candidateMonitor; + break; // Found the right-most monitor + } + } + } + + if (rightMonitor && rightMonitor != currentMonitor) { + // *** MOVE TO RIGHT END OF HORIZONTAL STACK *** + MONITORINFO rightInfo{}; + rightInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(rightMonitor, &rightInfo); + + // Calculate relative Y position to maintain cursor Y alignment + double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / + (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); + + int targetHeight = rightInfo.rcMonitor.bottom - rightInfo.rcMonitor.top; + newPos.y = rightInfo.rcMonitor.top + static_cast(relativeY * targetHeight); + newPos.x = rightInfo.rcMonitor.right - 1; // Right edge of right monitor + + // Clamp Y to target monitor bounds + newPos.y = max(rightInfo.rcMonitor.top, min(newPos.y, rightInfo.rcMonitor.bottom - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to right end of horizontal stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.x = currentMonitorInfo.rcMonitor.right - 1; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + else if (!wrapped && currentPos.x >= currentMonitorInfo.rcMonitor.right - 1) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: RIGHT EDGE DETECTED ======="); +#endif + + // Find the left-most monitor in the horizontal stack (same row) + HMONITOR leftMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search left from current position to find the left-most monitor in same row + for (int col = 0; col <= 2; col++) { // Start from left and work right + HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); + if (candidateMonitor) { + leftMonitor = candidateMonitor; + break; // Found the left-most monitor + } + } + } + + if (leftMonitor && leftMonitor != currentMonitor) { + // *** MOVE TO LEFT END OF HORIZONTAL STACK *** + MONITORINFO leftInfo{}; + leftInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(leftMonitor, &leftInfo); + + // Calculate relative Y position to maintain cursor Y alignment + double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / + (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); + + int targetHeight = leftInfo.rcMonitor.bottom - leftInfo.rcMonitor.top; + newPos.y = leftInfo.rcMonitor.top + static_cast(relativeY * targetHeight); + newPos.x = leftInfo.rcMonitor.left; // Left edge of left monitor + + // Clamp Y to target monitor bounds + newPos.y = max(leftInfo.rcMonitor.top, min(newPos.y, leftInfo.rcMonitor.bottom - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to left end of horizontal stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.x = currentMonitorInfo.rcMonitor.left; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + +#ifdef _DEBUG + if (wrapped) + { + Logger::info(L"CursorWrap DEBUG: ======= WRAP RESULT ======="); + Logger::info(L"CursorWrap DEBUG: Original: ({}, {}) -> New: ({}, {})", + currentPos.x, currentPos.y, newPos.x, newPos.y); + } + else + { + Logger::info(L"CursorWrap DEBUG: No wrapping performed - cursor not at edge"); + } + Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE END ======="); +#endif + + return newPos; + } + + // Add test method for monitor topology validation + void RunMonitorTopologyTests() + { +#ifdef _DEBUG + Logger::info(L"CursorWrap: Running monitor topology tests..."); + + // Test all 9 possible monitor positions in 3x3 grid + const char* gridNames[3][3] = { + {"TL", "TC", "TR"}, // Top-Left, Top-Center, Top-Right + {"ML", "MC", "MR"}, // Middle-Left, Middle-Center, Middle-Right + {"BL", "BC", "BR"} // Bottom-Left, Bottom-Center, Bottom-Right + }; + + for (int row = 0; row < 3; row++) + { + for (int col = 0; col < 3; col++) + { + HMONITOR monitor = m_topology.GetMonitorAt(row, col); + if (monitor) + { + std::string gridName(gridNames[row][col]); + std::wstring wGridName(gridName.begin(), gridName.end()); + Logger::info(L"CursorWrap TEST: Monitor at [{}][{}] ({}) exists", + row, col, wGridName.c_str()); + + // Test adjacent monitor finding + HMONITOR up = m_topology.FindAdjacentMonitor(monitor, -1, 0); + HMONITOR down = m_topology.FindAdjacentMonitor(monitor, 1, 0); + HMONITOR left = m_topology.FindAdjacentMonitor(monitor, 0, -1); + HMONITOR right = m_topology.FindAdjacentMonitor(monitor, 0, 1); + + Logger::info(L"CursorWrap TEST: Adjacent monitors - Up: {}, Down: {}, Left: {}, Right: {}", + up ? L"YES" : L"NO", down ? L"YES" : L"NO", + left ? L"YES" : L"NO", right ? L"YES" : L"NO"); + } + } + } + + Logger::info(L"CursorWrap: Monitor topology tests completed."); +#endif + } + + // Add method to trigger test suite (can be called via hotkey in debug builds) + void RunComprehensiveTests() + { +#ifdef _DEBUG + RunMonitorTopologyTests(); + + // Test cursor wrapping scenarios + Logger::info(L"CursorWrap: Testing cursor wrapping scenarios..."); + + // Simulate cursor positions at each monitor edge and verify expected behavior + for (const auto& monitor : m_monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + LogicalPosition pos = m_topology.GetPosition(hMonitor); + + if (pos.isValid) + { + Logger::info(L"CursorWrap TEST: Testing monitor at position [{}][{}]", pos.row, pos.col); + + // Test top edge + POINT topEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.top}; + POINT newPos = HandleMouseMove(topEdge); + Logger::info(L"CursorWrap TEST: Top edge ({}, {}) -> ({}, {})", + topEdge.x, topEdge.y, newPos.x, newPos.y); + + // Test bottom edge + POINT bottomEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.bottom - 1}; + newPos = HandleMouseMove(bottomEdge); + Logger::info(L"CursorWrap TEST: Bottom edge ({}, {}) -> ({}, {})", + bottomEdge.x, bottomEdge.y, newPos.x, newPos.y); + + // Test left edge + POINT leftEdge = {monitor.rect.left, (monitor.rect.top + monitor.rect.bottom) / 2}; + newPos = HandleMouseMove(leftEdge); + Logger::info(L"CursorWrap TEST: Left edge ({}, {}) -> ({}, {})", + leftEdge.x, leftEdge.y, newPos.x, newPos.y); + + // Test right edge + POINT rightEdge = {monitor.rect.right - 1, (monitor.rect.top + monitor.rect.bottom) / 2}; + newPos = HandleMouseMove(rightEdge); + Logger::info(L"CursorWrap TEST: Right edge ({}, {}) -> ({}, {})", + rightEdge.x, rightEdge.y, newPos.x, newPos.y); + } + } + + Logger::info(L"CursorWrap: Comprehensive tests completed."); +#endif + } +}; + +// Implementation of MonitorTopology methods +void MonitorTopology::Initialize(const std::vector& monitors) +{ + // Clear existing data + grid.assign(3, std::vector(3, nullptr)); + monitorToPosition.clear(); + positionToMonitor.clear(); + + if (monitors.empty()) return; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION START ======="); + Logger::info(L"CursorWrap DEBUG: Initializing topology for {} monitors", monitors.size()); + for (const auto& monitor : monitors) + { + Logger::info(L"CursorWrap DEBUG: Monitor {}: bounds=({},{},{},{}), isPrimary={}", + monitor.monitorId, monitor.rect.left, monitor.rect.top, + monitor.rect.right, monitor.rect.bottom, monitor.isPrimary); + } +#endif + + // Special handling for 2 monitors - use physical position, not discovery order + if (monitors.size() == 2) + { + // Determine if arrangement is horizontal or vertical by comparing centers + POINT center0 = {(monitors[0].rect.left + monitors[0].rect.right) / 2, + (monitors[0].rect.top + monitors[0].rect.bottom) / 2}; + POINT center1 = {(monitors[1].rect.left + monitors[1].rect.right) / 2, + (monitors[1].rect.top + monitors[1].rect.bottom) / 2}; + + int xDiff = abs(center0.x - center1.x); + int yDiff = abs(center0.y - center1.y); + + bool isHorizontal = xDiff > yDiff; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor centers: M0=({}, {}), M1=({}, {})", + center0.x, center0.y, center1.x, center1.y); + Logger::info(L"CursorWrap DEBUG: Differences: X={}, Y={}, IsHorizontal={}", + xDiff, yDiff, isHorizontal); +#endif + + if (isHorizontal) + { + // Horizontal arrangement - place in middle row [1,0] and [1,2] + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + POINT center = {(monitor.rect.left + monitor.rect.right) / 2, + (monitor.rect.top + monitor.rect.bottom) / 2}; + + int row = 1; // Middle row + int col = (center.x < (center0.x + center1.x) / 2) ? 0 : 2; // Left or right based on center + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} (horizontal) placed at grid[{}][{}]", + monitor.monitorId, row, col); +#endif + } + } + else + { + // *** VERTICAL ARRANGEMENT - CRITICAL LOGIC *** + // Sort monitors by Y coordinate to determine vertical order + std::vector> sortedMonitors; + for (int i = 0; i < 2; i++) { + sortedMonitors.push_back({i, monitors[i]}); + } + + // Sort by Y coordinate (top to bottom) + std::sort(sortedMonitors.begin(), sortedMonitors.end(), + [](const std::pair& a, const std::pair& b) { + int centerA = (a.second.rect.top + a.second.rect.bottom) / 2; + int centerB = (b.second.rect.top + b.second.rect.bottom) / 2; + return centerA < centerB; // Top first + }); + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL ARRANGEMENT DETECTED"); + Logger::info(L"CursorWrap DEBUG: Top monitor: ID={}, Y-center={}", + sortedMonitors[0].second.monitorId, + (sortedMonitors[0].second.rect.top + sortedMonitors[0].second.rect.bottom) / 2); + Logger::info(L"CursorWrap DEBUG: Bottom monitor: ID={}, Y-center={}", + sortedMonitors[1].second.monitorId, + (sortedMonitors[1].second.rect.top + sortedMonitors[1].second.rect.bottom) / 2); +#endif + + // Place monitors in grid based on sorted order + for (int i = 0; i < 2; i++) { + const auto& monitorPair = sortedMonitors[i]; + const auto& monitor = monitorPair.second; + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + + int col = 1; // Middle column for vertical arrangement + int row = (i == 0) ? 0 : 2; // Top monitor at row 0, bottom at row 2 + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} (vertical) placed at grid[{}][{}] - {} position", + monitor.monitorId, row, col, (i == 0) ? L"TOP" : L"BOTTOM"); +#endif + } + } + } + else + { + // For more than 2 monitors, use the general algorithm + RECT totalBounds = monitors[0].rect; + for (const auto& monitor : monitors) + { + totalBounds.left = min(totalBounds.left, monitor.rect.left); + totalBounds.top = min(totalBounds.top, monitor.rect.top); + totalBounds.right = max(totalBounds.right, monitor.rect.right); + totalBounds.bottom = max(totalBounds.bottom, monitor.rect.bottom); + } + + int totalWidth = totalBounds.right - totalBounds.left; + int totalHeight = totalBounds.bottom - totalBounds.top; + int gridWidth = max(1, totalWidth / 3); + int gridHeight = max(1, totalHeight / 3); + + // Place monitors in the 3x3 grid based on their center points + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + + // Calculate center point of monitor + int centerX = (monitor.rect.left + monitor.rect.right) / 2; + int centerY = (monitor.rect.top + monitor.rect.bottom) / 2; + + // Map to grid position + int col = (centerX - totalBounds.left) / gridWidth; + int row = (centerY - totalBounds.top) / gridHeight; + + // Ensure we stay within bounds + col = max(0, min(2, col)); + row = max(0, min(2, row)); + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}], center=({}, {})", + monitor.monitorId, row, col, centerX, centerY); +#endif + } + } + +#ifdef _DEBUG + // *** CRITICAL: Print topology map using OutputDebugString for debug builds *** + Logger::info(L"CursorWrap DEBUG: ======= FINAL TOPOLOGY MAP ======="); + OutputDebugStringA("CursorWrap TOPOLOGY MAP:\n"); + for (int r = 0; r < 3; r++) + { + std::string rowStr = " "; + for (int c = 0; c < 3; c++) + { + if (grid[r][c]) + { + // Find monitor ID for this handle + int monitorId = -1; + for (const auto& monitor : monitors) + { + HMONITOR handle = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + if (handle == grid[r][c]) + { + monitorId = monitor.monitorId + 1; // Convert to 1-based for display + break; + } + } + rowStr += std::to_string(monitorId) + " "; + } + else + { + rowStr += ". "; + } + } + rowStr += "\n"; + OutputDebugStringA(rowStr.c_str()); + + // Also log to PowerToys logger + std::wstring wRowStr(rowStr.begin(), rowStr.end()); + Logger::info(wRowStr.c_str()); + } + OutputDebugStringA("======= END TOPOLOGY MAP =======\n"); + + // Additional validation logging + Logger::info(L"CursorWrap DEBUG: ======= GRID POSITION VALIDATION ======="); + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + LogicalPosition pos = GetPosition(hMonitor); + if (pos.isValid) + { + Logger::info(L"CursorWrap DEBUG: Monitor {} -> grid[{}][{}]", monitor.monitorId, pos.row, pos.col); + OutputDebugStringA(("Monitor " + std::to_string(monitor.monitorId) + " -> grid[" + std::to_string(pos.row) + "][" + std::to_string(pos.col) + "]\n").c_str()); + + // Test adjacent finding + HMONITOR up = FindAdjacentMonitor(hMonitor, -1, 0); + HMONITOR down = FindAdjacentMonitor(hMonitor, 1, 0); + HMONITOR left = FindAdjacentMonitor(hMonitor, 0, -1); + HMONITOR right = FindAdjacentMonitor(hMonitor, 0, 1); + + Logger::info(L"CursorWrap DEBUG: Monitor {} adjacents - Up: {}, Down: {}, Left: {}, Right: {}", + monitor.monitorId, up ? L"YES" : L"NO", down ? L"YES" : L"NO", + left ? L"YES" : L"NO", right ? L"YES" : L"NO"); + } + } + Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION COMPLETE ======="); +#endif +} + +LogicalPosition MonitorTopology::GetPosition(HMONITOR monitor) const +{ + auto it = monitorToPosition.find(monitor); + if (it != monitorToPosition.end()) + { + return it->second; + } + return {-1, -1, false}; +} + +HMONITOR MonitorTopology::GetMonitorAt(int row, int col) const +{ + if (row >= 0 && row < 3 && col >= 0 && col < 3) + { + return grid[row][col]; + } + return nullptr; +} + +HMONITOR MonitorTopology::FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const +{ + LogicalPosition currentPos = GetPosition(current); + if (!currentPos.isValid) return nullptr; + + int newRow = currentPos.row + deltaRow; + int newCol = currentPos.col + deltaCol; + + return GetMonitorAt(newRow, newCol); +} + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new CursorWrap(); +} diff --git a/installer/PowerToysSetupCustomActions/packages.config b/src/modules/MouseUtils/CursorWrap/packages.config similarity index 73% rename from installer/PowerToysSetupCustomActions/packages.config rename to src/modules/MouseUtils/CursorWrap/packages.config index 09bfc449e2..2c5d71ae86 100644 --- a/installer/PowerToysSetupCustomActions/packages.config +++ b/src/modules/MouseUtils/CursorWrap/packages.config @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.cpp b/src/modules/MouseUtils/CursorWrap/pch.cpp new file mode 100644 index 0000000000..17305716aa --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.h b/src/modules/MouseUtils/CursorWrap/pch.h new file mode 100644 index 0000000000..86f11c99ba --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.h @@ -0,0 +1,13 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#include + +#include +#include +#include + +// Note: Common includes moved to individual source files due to include path issues +// #include +// #include +// #include \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/resource.h b/src/modules/MouseUtils/CursorWrap/resource.h new file mode 100644 index 0000000000..9b49c0e3cc --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/resource.h @@ -0,0 +1,4 @@ +#pragma once + +#define IDS_CURSORWRAP_NAME 101 +#define IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG 102 diff --git a/src/modules/MouseUtils/CursorWrap/trace.cpp b/src/modules/MouseUtils/CursorWrap/trace.cpp new file mode 100644 index 0000000000..ebfe32c23c --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.cpp @@ -0,0 +1,31 @@ +#include "pch.h" +#include "trace.h" + +#include "../../../../common/Telemetry/TraceBase.h" + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::EnableCursorWrap(const bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "CursorWrap_EnableCursorWrap", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/trace.h b/src/modules/MouseUtils/CursorWrap/trace.h new file mode 100644 index 0000000000..b2f6a9a8eb --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +class Trace : public telemetry::TraceBase +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void EnableCursorWrap(const bool enabled) noexcept; +}; \ No newline at end of file diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp index 3049d3740c..f953af0fdd 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp @@ -8,21 +8,28 @@ #include "common/utils/process_path.h" #include "common/utils/excluded_apps.h" #include "common/utils/MsWindowsSettings.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + #include -#ifdef COMPOSITION namespace winrt { using namespace winrt::Windows::System; - using namespace winrt::Windows::UI::Composition; } -namespace ABI -{ - using namespace ABI::Windows::System; - using namespace ABI::Windows::UI::Composition::Desktop; -} -#endif +namespace muxc = winrt::Microsoft::UI::Composition; +namespace muxx = winrt::Microsoft::UI::Xaml; +namespace muxxc = winrt::Microsoft::UI::Xaml::Controls; +namespace muxxh = winrt::Microsoft::UI::Xaml::Hosting; #pragma region Super_Sonar_Base_Code @@ -70,11 +77,11 @@ protected: int m_sonarRadius = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_RADIUS; int m_sonarZoomFactor = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_INITIAL_ZOOM; DWORD m_fadeDuration = FIND_MY_MOUSE_DEFAULT_ANIMATION_DURATION_MS; - int m_finalAlphaNumerator = FIND_MY_MOUSE_DEFAULT_OVERLAY_OPACITY; + int m_finalAlphaNumerator = 100; // legacy (root now always animates to 1.0; kept for GDI fallback compatibility) std::vector m_excludedApps; int m_shakeMinimumDistance = FIND_MY_MOUSE_DEFAULT_SHAKE_MINIMUM_DISTANCE; static constexpr int FinalAlphaDenominator = 100; - winrt::DispatcherQueueController m_dispatcherQueueController{ nullptr }; + winrt::Microsoft::UI::Dispatching::DispatcherQueueController m_dispatcherQueueController{ nullptr }; // Don't consider movements started past these milliseconds to detect shaking. int m_shakeIntervalMs = FIND_MY_MOUSE_DEFAULT_SHAKE_INTERVAL_MS; @@ -82,7 +89,6 @@ protected: int m_shakeFactor = FIND_MY_MOUSE_DEFAULT_SHAKE_FACTOR; private: - // Save the mouse movement that occurred in any direction. struct PointerRecentMovement { @@ -159,7 +165,6 @@ bool SuperSonar::Initialize(HINSTANCE hinst) SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); WNDCLASS wc{}; - if (!GetClassInfoW(hinst, className, &wc)) { wc.lpfnWndProc = s_WndProc; @@ -171,14 +176,28 @@ bool SuperSonar::Initialize(HINSTANCE hinst) if (!RegisterClassW(&wc)) { + Logger::error("RegisterClassW failed. GetLastError={}", GetLastError()); return false; } } + // else: class already registered m_hwndOwner = CreateWindow(L"static", nullptr, WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, hinst, nullptr); + if (!m_hwndOwner) + { + Logger::error("Failed to create owner window. GetLastError={}", GetLastError()); + return false; + } DWORD exStyle = WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | Shim()->GetExtendedStyle(); - return CreateWindowExW(exStyle, className, windowTitle, WS_POPUP, CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, m_hwndOwner, nullptr, hinst, this) != nullptr; + HWND created = CreateWindowExW(exStyle, className, windowTitle, WS_POPUP, CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, m_hwndOwner, nullptr, hinst, this); + if (!created) + { + Logger::error("CreateWindowExW failed. GetLastError={}", GetLastError()); + return false; + } + + return true; } template @@ -226,7 +245,8 @@ LRESULT SuperSonar::BaseWndProc(UINT message, WPARAM wParam, LPARAM lParam) n switch (message) { case WM_CREATE: - if(!OnSonarCreate()) return -1; + if (!OnSonarCreate()) + return -1; UpdateMouseSnooping(); return 0; @@ -314,8 +334,7 @@ void SuperSonar::OnSonarKeyboardInput(RAWINPUT const& input) return; } - if ((m_activationMethod != FindMyMouseActivationMethod::DoubleRightControlKey && m_activationMethod != FindMyMouseActivationMethod::DoubleLeftControlKey) - || input.data.keyboard.VKey != VK_CONTROL) + if ((m_activationMethod != FindMyMouseActivationMethod::DoubleRightControlKey && m_activationMethod != FindMyMouseActivationMethod::DoubleLeftControlKey) || input.data.keyboard.VKey != VK_CONTROL) { StopSonar(); return; @@ -326,8 +345,7 @@ void SuperSonar::OnSonarKeyboardInput(RAWINPUT const& input) bool leftCtrlPressed = (input.data.keyboard.Flags & RI_KEY_E0) == 0; bool rightCtrlPressed = (input.data.keyboard.Flags & RI_KEY_E0) != 0; - if ((m_activationMethod == FindMyMouseActivationMethod::DoubleRightControlKey && !rightCtrlPressed) - || (m_activationMethod == FindMyMouseActivationMethod::DoubleLeftControlKey && !leftCtrlPressed)) + if ((m_activationMethod == FindMyMouseActivationMethod::DoubleRightControlKey && !rightCtrlPressed) || (m_activationMethod == FindMyMouseActivationMethod::DoubleLeftControlKey && !leftCtrlPressed)) { StopSonar(); return; @@ -376,7 +394,6 @@ void SuperSonar::OnSonarKeyboardInput(RAWINPUT const& input) GetCursorPos(&m_lastKeyPos); UpdateMouseSnooping(); } - Logger::info("Detecting double left control click with {} ms interval.", doubleClickInterval); m_lastKeyTime = now; m_lastKeyPos = ptCursor; } @@ -402,14 +419,13 @@ template void SuperSonar::DetectShake() { ULONGLONG shakeStartTick = GetTickCount64() - m_shakeIntervalMs; - + // Prune the story of movements for those movements that started too long ago. std::erase_if(m_movementHistory, [shakeStartTick](const PointerRecentMovement& movement) { return movement.tick < shakeStartTick; }); - - + double distanceTravelled = 0; - LONGLONG currentX=0, minX=0, maxX=0; - LONGLONG currentY=0, minY=0, maxY=0; + LONGLONG currentX = 0, minX = 0, maxX = 0; + LONGLONG currentY = 0, minY = 0, maxY = 0; for (const PointerRecentMovement& movement : m_movementHistory) { @@ -421,23 +437,22 @@ void SuperSonar::DetectShake() minY = min(currentY, minY); maxY = max(currentY, maxY); } - + if (distanceTravelled < m_shakeMinimumDistance) { return; } // Size of the rectangle that the pointer moved in. - double rectangleWidth = static_cast(maxX) - minX; - double rectangleHeight = static_cast(maxY) - minY; + double rectangleWidth = static_cast(maxX) - minX; + double rectangleHeight = static_cast(maxY) - minY; double diagonal = sqrt(rectangleWidth * rectangleWidth + rectangleHeight * rectangleHeight); - if (diagonal > 0 && distanceTravelled / diagonal > (m_shakeFactor/100.f)) + if (diagonal > 0 && distanceTravelled / diagonal > (m_shakeFactor / 100.f)) { m_movementHistory.clear(); StartSonar(); } - } template @@ -453,7 +468,7 @@ void SuperSonar::OnSonarMouseInput(RAWINPUT const& input) { LONG relativeX = 0; LONG relativeY = 0; - if ((input.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE) == MOUSE_MOVE_ABSOLUTE && (input.data.mouse.lLastX!=0 || input.data.mouse.lLastY!=0)) + if ((input.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE) == MOUSE_MOVE_ABSOLUTE && (input.data.mouse.lLastX != 0 || input.data.mouse.lLastY != 0)) { // Getting absolute mouse coordinates. Likely inside a VM / RDP session. if (m_seenAnAbsoluteMousePosition) @@ -482,7 +497,7 @@ void SuperSonar::OnSonarMouseInput(RAWINPUT const& input) } else { - m_movementHistory.push_back({ .diff = { .x=relativeX, .y=relativeY }, .tick = GetTickCount64() }); + m_movementHistory.push_back({ .diff = { .x = relativeX, .y = relativeY }, .tick = GetTickCount64() }); // Mouse movement changed directions. Take the opportunity do detect shake. DetectShake(); } @@ -491,7 +506,6 @@ void SuperSonar::OnSonarMouseInput(RAWINPUT const& input) { m_movementHistory.push_back({ .diff = { .x = relativeX, .y = relativeY }, .tick = GetTickCount64() }); } - } if (input.data.mouse.usButtonFlags) @@ -518,7 +532,6 @@ void SuperSonar::StartSonar() return; } - Logger::info("Focusing the sonar on the mouse cursor."); Trace::MousePointerFocused(); // Cover the entire virtual screen. // HACK: Draw with 1 pixel off. Otherwise, Windows glitches the task bar transparency when a transparent window fill the whole screen. @@ -633,12 +646,26 @@ struct CompositionSpotlight : SuperSonar DWORD GetExtendedStyle() { - return WS_EX_NOREDIRECTIONBITMAP; + // Remove WS_EX_NOREDIRECTIONBITMAP for Composition/XAML to allow DWM redirection. + return 0; } void AfterMoveSonar() { - m_spotlight.Offset({ static_cast(m_sonarPos.x), static_cast(m_sonarPos.y), 0.0f }); + const float scale = static_cast(m_surface.XamlRoot().RasterizationScale()); + // Move gradient center + if (m_spotlightMaskGradient) + { + m_spotlightMaskGradient.EllipseCenter({ static_cast(m_sonarPos.x) / scale, + static_cast(m_sonarPos.y) / scale }); + } + // Move spotlight visual (color fill) below masked backdrop + if (m_spotlight) + { + m_spotlight.Offset({ static_cast(m_sonarPos.x) / scale, + static_cast(m_sonarPos.y) / scale, + 0.0f }); + } } LRESULT WndProc(UINT message, WPARAM wParam, LPARAM lParam) noexcept @@ -646,24 +673,29 @@ struct CompositionSpotlight : SuperSonar switch (message) { case WM_CREATE: - return OnCompositionCreate() && BaseWndProc(message, wParam, lParam); + if (!OnCompositionCreate()) + return -1; + return BaseWndProc(message, wParam, lParam); case WM_OPACITY_ANIMATION_COMPLETED: OnOpacityAnimationCompleted(); break; + case WM_SIZE: + UpdateIslandSize(); + break; } return BaseWndProc(message, wParam, lParam); } void SetSonarVisibility(bool visible) { - m_batch = m_compositor.GetCommitBatch(winrt::CompositionBatchTypes::Animation); + m_batch = m_compositor.GetCommitBatch(muxc::CompositionBatchTypes::Animation); BOOL isEnabledAnimations = GetAnimationsEnabled(); m_animation.Duration(std::chrono::milliseconds{ isEnabledAnimations ? m_fadeDuration : 1 }); m_batch.Completed([hwnd = m_hwnd](auto&&, auto&&) { PostMessage(hwnd, WM_OPACITY_ANIMATION_COMPLETED, 0, 0); }); - m_root.Opacity(visible ? static_cast(m_finalAlphaNumerator) / FinalAlphaDenominator : 0.0f); + m_root.Opacity(visible ? 1.0f : 0.0f); if (visible) { ShowWindow(m_hwnd, SW_SHOWNOACTIVATE); @@ -679,54 +711,138 @@ private: bool OnCompositionCreate() try { - // We need a dispatcher queue. - DispatcherQueueOptions options = { - sizeof(options), - DQTYPE_THREAD_CURRENT, - DQTAT_COM_ASTA, - }; - ABI::IDispatcherQueueController* controller; - winrt::check_hresult(CreateDispatcherQueueController(options, &controller)); - *winrt::put_abi(m_dispatcherQueueController) = controller; + // Creating composition resources + // Ensure a DispatcherQueue bound to this thread (required by WinAppSDK composition/XAML) + if (!m_dispatcherQueueController) + { + // Ensure COM is initialized + try + { + winrt::init_apartment(winrt::apartment_type::single_threaded); + // COM STA initialized + } + catch (const winrt::hresult_error& e) + { + Logger::error("Failed to initialize COM apartment: {}", winrt::to_string(e.message())); + return false; + } - // Create the compositor for our window. - m_compositor = winrt::Compositor(); - ABI::IDesktopWindowTarget* target; - winrt::check_hresult(m_compositor.as()->CreateDesktopWindowTarget(m_hwnd, false, &target)); - *winrt::put_abi(m_target) = target; + try + { + m_dispatcherQueueController = + winrt::Microsoft::UI::Dispatching::DispatcherQueueController::CreateOnCurrentThread(); + // DispatcherQueueController created + } + catch (const winrt::hresult_error& e) + { + Logger::error("Failed to create DispatcherQueueController: {}", winrt::to_string(e.message())); + return false; + } + } - // Our composition tree: + // 1) Create a XAML island and attach it to this HWND + try + { + m_island = winrt::Microsoft::UI::Xaml::Hosting::DesktopWindowXamlSource{}; + auto windowId = winrt::Microsoft::UI::GetWindowIdFromWindow(m_hwnd); + m_island.Initialize(windowId); + // Xaml source initialized + } + catch (const winrt::hresult_error& e) + { + Logger::error("Failed to create XAML island: {}", winrt::to_string(e.message())); + return false; + } + + UpdateIslandSize(); + // Island size set + + // 2) Create a XAML container to host the Composition child visual + m_surface = winrt::Microsoft::UI::Xaml::Controls::Grid{}; + + // A transparent background keeps hit-testing consistent vs. null brush + m_surface.Background(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush{ + winrt::Microsoft::UI::Colors::Transparent() }); + m_surface.HorizontalAlignment(muxx::HorizontalAlignment::Stretch); + m_surface.VerticalAlignment(muxx::VerticalAlignment::Stretch); + + m_island.Content(m_surface); + + // 3) Get the compositor from the XAML visual tree (pure MUXC path) + try + { + auto elementVisual = + winrt::Microsoft::UI::Xaml::Hosting::ElementCompositionPreview::GetElementVisual(m_surface); + m_compositor = elementVisual.Compositor(); + // Compositor acquired + } + catch (const winrt::hresult_error& e) + { + Logger::error("Failed to get compositor: {}", winrt::to_string(e.message())); + return false; + } + + // 4) Build the composition tree // - // [root] ContainerVisual - // \ LayerVisual - // \[gray backdrop] - // [spotlight] + // [root] ContainerVisual (fills host) + // \ LayerVisual + // \ [backdrop dim * radial gradient mask (hole)] m_root = m_compositor.CreateContainerVisual(); - m_root.RelativeSizeAdjustment({ 1.0f, 1.0f }); // fill the parent + m_root.RelativeSizeAdjustment({ 1.0f, 1.0f }); m_root.Opacity(0.0f); - m_target.Root(m_root); + + // Insert our root as a hand-in Visual under the XAML element + winrt::Microsoft::UI::Xaml::Hosting::ElementCompositionPreview::SetElementChildVisual(m_surface, m_root); auto layer = m_compositor.CreateLayerVisual(); - layer.RelativeSizeAdjustment({ 1.0f, 1.0f }); // fill the parent + layer.RelativeSizeAdjustment({ 1.0f, 1.0f }); m_root.Children().InsertAtTop(layer); - m_backdrop = m_compositor.CreateSpriteVisual(); - m_backdrop.RelativeSizeAdjustment({ 1.0f, 1.0f }); // fill the parent - m_backdrop.Brush(m_compositor.CreateColorBrush(m_backgroundColor)); - layer.Children().InsertAtTop(m_backdrop); + const float scale = static_cast(m_surface.XamlRoot().RasterizationScale()); + const float rDip = m_sonarRadiusFloat / scale; + const float zoom = static_cast(m_sonarZoomFactor); - m_circleGeometry = m_compositor.CreateEllipseGeometry(); // radius set via expression animation + // Spotlight shape (below backdrop, visible through hole) + m_circleGeometry = m_compositor.CreateEllipseGeometry(); m_circleShape = m_compositor.CreateSpriteShape(m_circleGeometry); m_circleShape.FillBrush(m_compositor.CreateColorBrush(m_spotlightColor)); - m_circleShape.Offset({ m_sonarRadiusFloat * m_sonarZoomFactor, m_sonarRadiusFloat * m_sonarZoomFactor }); + m_circleShape.Offset({ rDip * zoom, rDip * zoom }); m_spotlight = m_compositor.CreateShapeVisual(); - m_spotlight.Size({ m_sonarRadiusFloat * 2 * m_sonarZoomFactor, m_sonarRadiusFloat * 2 * m_sonarZoomFactor }); + m_spotlight.Size({ rDip * 2 * zoom, rDip * 2 * zoom }); m_spotlight.AnchorPoint({ 0.5f, 0.5f }); m_spotlight.Shapes().Append(m_circleShape); - layer.Children().InsertAtTop(m_spotlight); - // Implicitly animate the alpha. + // Dim color (source) + m_dimColorBrush = m_compositor.CreateColorBrush(m_backgroundColor); + // Radial gradient mask (center transparent, outer opaque) + m_spotlightMaskGradient = m_compositor.CreateRadialGradientBrush(); + m_spotlightMaskGradient.MappingMode(muxc::CompositionMappingMode::Absolute); + m_maskStopCenter = m_compositor.CreateColorGradientStop(); + m_maskStopCenter.Offset(0.0f); + m_maskStopCenter.Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0)); + m_maskStopInner = m_compositor.CreateColorGradientStop(); + m_maskStopInner.Offset(0.995f); + m_maskStopInner.Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0)); + m_maskStopOuter = m_compositor.CreateColorGradientStop(); + m_maskStopOuter.Offset(1.0f); + m_maskStopOuter.Color(winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255)); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopCenter); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopInner); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopOuter); + m_spotlightMaskGradient.EllipseCenter({ rDip * zoom, rDip * zoom }); + m_spotlightMaskGradient.EllipseRadius({ rDip * zoom, rDip * zoom }); + + m_maskBrush = m_compositor.CreateMaskBrush(); + m_maskBrush.Source(m_dimColorBrush); + m_maskBrush.Mask(m_spotlightMaskGradient); + + m_backdrop = m_compositor.CreateSpriteVisual(); + m_backdrop.RelativeSizeAdjustment({ 1.0f, 1.0f }); + m_backdrop.Brush(m_maskBrush); + layer.Children().InsertAtTop(m_backdrop); + + // 5) Implicit opacity animation on the root m_animation = m_compositor.CreateScalarKeyFrameAnimation(); m_animation.Target(L"Opacity"); m_animation.InsertExpressionKeyFrame(1.0f, L"this.FinalValue"); @@ -735,20 +851,31 @@ private: collection.Insert(L"Opacity", m_animation); m_root.ImplicitAnimations(collection); - // Radius of spotlight shrinks as opacity increases. - // At opacity zero, it is m_sonarRadius * SonarZoomFactor. - // At maximum opacity, it is m_sonarRadius. + // 6) Spotlight radius shrinks as opacity increases (expression animation) auto radiusExpression = m_compositor.CreateExpressionAnimation(); radiusExpression.SetReferenceParameter(L"Root", m_root); - wchar_t expressionText[256]; - winrt::check_hresult(StringCchPrintfW(expressionText, ARRAYSIZE(expressionText), L"Lerp(Vector2(%d, %d), Vector2(%d, %d), Root.Opacity * %d / %d)", m_sonarRadius * m_sonarZoomFactor, m_sonarRadius * m_sonarZoomFactor, m_sonarRadius, m_sonarRadius, FinalAlphaDenominator, m_finalAlphaNumerator)); - radiusExpression.Expression(expressionText); - m_circleGeometry.StartAnimation(L"Radius", radiusExpression); + wchar_t expressionText[256]; + winrt::check_hresult(StringCchPrintfW( + expressionText, ARRAYSIZE(expressionText), L"Lerp(Vector2(%d, %d), Vector2(%d, %d), Root.Opacity)", m_sonarRadius * m_sonarZoomFactor, m_sonarRadius * m_sonarZoomFactor, m_sonarRadius, m_sonarRadius)); + + radiusExpression.Expression(expressionText); + m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", radiusExpression); + // Also animate spotlight geometry radius for visual consistency + if (m_circleGeometry) + { + auto radiusExpression2 = m_compositor.CreateExpressionAnimation(); + radiusExpression2.SetReferenceParameter(L"Root", m_root); + radiusExpression2.Expression(expressionText); + m_circleGeometry.StartAnimation(L"Radius", radiusExpression2); + } + + // Composition created successfully return true; } - catch (...) + catch (const winrt::hresult_error& e) { + Logger::error("Failed to create FindMyMouse visual: {}", winrt::to_string(e.message())); return false; } @@ -760,11 +887,27 @@ private: } } + void UpdateIslandSize() + { + if (!m_island) + return; + + RECT rc{}; + if (!GetClientRect(m_hwnd, &rc)) + return; + + const int width = rc.right - rc.left; + const int height = rc.bottom - rc.top; + + auto bridge = m_island.SiteBridge(); + bridge.MoveAndResize(winrt::Windows::Graphics::RectInt32{ 0, 0, width, height }); + } + public: - void ApplySettings(const FindMyMouseSettings& settings, bool applyToRuntimeObjects) { + void ApplySettings(const FindMyMouseSettings& settings, bool applyToRuntimeObjects) + { if (!applyToRuntimeObjects) { - // Runtime objects not created yet. Just update fields. m_sonarRadius = settings.spotlightRadius; m_sonarRadiusFloat = static_cast(m_sonarRadius); m_backgroundColor = settings.backgroundColor; @@ -773,7 +916,6 @@ public: m_includeWinKey = settings.includeWinKey; m_doNotActivateOnGameMode = settings.doNotActivateOnGameMode; m_fadeDuration = settings.animationDurationMs > 0 ? settings.animationDurationMs : 1; - m_finalAlphaNumerator = settings.overlayOpacity; m_sonarZoomFactor = settings.spotlightInitialZoom; m_excludedApps = settings.excludedApps; m_shakeMinimumDistance = settings.shakeMinimumDistance; @@ -782,11 +924,9 @@ public: } else { - // Runtime objects already created. Should update in the owner thread. if (m_dispatcherQueueController == nullptr) { Logger::warn("Tried accessing the dispatch queue controller before it was initialized."); - // No dispatcher Queue Controller? Means initialization still hasn't run, so settings will be applied then. return; } auto dispatcherQueue = m_dispatcherQueueController.DispatcherQueue(); @@ -794,7 +934,6 @@ public: bool enqueueSucceeded = dispatcherQueue.TryEnqueue([=]() { if (!m_destroyed) { - // Runtime objects not created yet. Just update fields. m_sonarRadius = localSettings.spotlightRadius; m_sonarRadiusFloat = static_cast(m_sonarRadius); m_backgroundColor = localSettings.backgroundColor; @@ -803,7 +942,6 @@ public: m_includeWinKey = localSettings.includeWinKey; m_doNotActivateOnGameMode = localSettings.doNotActivateOnGameMode; m_fadeDuration = localSettings.animationDurationMs > 0 ? localSettings.animationDurationMs : 1; - m_finalAlphaNumerator = localSettings.overlayOpacity; m_sonarZoomFactor = localSettings.spotlightInitialZoom; m_excludedApps = localSettings.excludedApps; m_shakeMinimumDistance = localSettings.shakeMinimumDistance; @@ -812,20 +950,41 @@ public: UpdateMouseSnooping(); // For the shake mouse activation method // Apply new settings to runtime composition objects. - m_backdrop.Brush().as().Color(m_backgroundColor); - m_circleShape.FillBrush().as().Color(m_spotlightColor); - m_circleShape.Offset({ m_sonarRadiusFloat * m_sonarZoomFactor, m_sonarRadiusFloat * m_sonarZoomFactor }); - m_spotlight.Size({ m_sonarRadiusFloat * 2 * m_sonarZoomFactor, m_sonarRadiusFloat * 2 * m_sonarZoomFactor }); - m_animation.Duration(std::chrono::milliseconds{ m_fadeDuration }); - m_circleGeometry.StopAnimation(L"Radius"); - - // Update animation + if (m_dimColorBrush) + { + m_dimColorBrush.Color(m_backgroundColor); + } + if (m_circleShape) + { + if (auto brush = m_circleShape.FillBrush().try_as()) + { + brush.Color(m_spotlightColor); + } + } + const float scale = static_cast(m_surface.XamlRoot().RasterizationScale()); + const float rDip = m_sonarRadiusFloat / scale; + const float zoom = static_cast(m_sonarZoomFactor); + m_spotlightMaskGradient.StopAnimation(L"EllipseRadius"); + m_spotlightMaskGradient.EllipseCenter({ rDip * zoom, rDip * zoom }); + if (m_spotlight) + { + m_spotlight.Size({ rDip * 2 * zoom, rDip * 2 * zoom }); + m_circleShape.Offset({ rDip * zoom, rDip * zoom }); + } auto radiusExpression = m_compositor.CreateExpressionAnimation(); radiusExpression.SetReferenceParameter(L"Root", m_root); wchar_t expressionText[256]; - winrt::check_hresult(StringCchPrintfW(expressionText, ARRAYSIZE(expressionText), L"Lerp(Vector2(%d, %d), Vector2(%d, %d), Root.Opacity * %d / %d)", m_sonarRadius * m_sonarZoomFactor, m_sonarRadius * m_sonarZoomFactor, m_sonarRadius, m_sonarRadius, FinalAlphaDenominator, m_finalAlphaNumerator)); + winrt::check_hresult(StringCchPrintfW(expressionText, ARRAYSIZE(expressionText), L"Lerp(Vector2(%d, %d), Vector2(%d, %d), Root.Opacity)", m_sonarRadius * m_sonarZoomFactor, m_sonarRadius * m_sonarZoomFactor, m_sonarRadius, m_sonarRadius)); radiusExpression.Expression(expressionText); - m_circleGeometry.StartAnimation(L"Radius", radiusExpression); + m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", radiusExpression); + if (m_circleGeometry) + { + m_circleGeometry.StopAnimation(L"Radius"); + auto radiusExpression2 = m_compositor.CreateExpressionAnimation(); + radiusExpression2.SetReferenceParameter(L"Root", m_root); + radiusExpression2.Expression(expressionText); + m_circleGeometry.StartAnimation(L"Radius", radiusExpression2); + } } }); if (!enqueueSucceeded) @@ -836,17 +995,27 @@ public: } private: - winrt::Compositor m_compositor{ nullptr }; - winrt::Desktop::DesktopWindowTarget m_target{ nullptr }; - winrt::ContainerVisual m_root{ nullptr }; - winrt::CompositionEllipseGeometry m_circleGeometry{ nullptr }; - winrt::ShapeVisual m_spotlight{ nullptr }; - winrt::CompositionCommitBatch m_batch{ nullptr }; - winrt::SpriteVisual m_backdrop{ nullptr }; - winrt::CompositionSpriteShape m_circleShape{ nullptr }; + muxc::Compositor m_compositor{ nullptr }; + muxxh::DesktopWindowXamlSource m_island{ nullptr }; + muxxc::Grid m_surface{ nullptr }; + + muxc::ContainerVisual m_root{ nullptr }; + muxc::CompositionCommitBatch m_batch{ nullptr }; + muxc::SpriteVisual m_backdrop{ nullptr }; + // Spotlight shape visuals + muxc::CompositionEllipseGeometry m_circleGeometry{ nullptr }; + muxc::ShapeVisual m_spotlight{ nullptr }; + muxc::CompositionSpriteShape m_circleShape{ nullptr }; + // Radial gradient mask components + muxc::CompositionMaskBrush m_maskBrush{ nullptr }; + muxc::CompositionColorBrush m_dimColorBrush{ nullptr }; + muxc::CompositionRadialGradientBrush m_spotlightMaskGradient{ nullptr }; + muxc::CompositionColorGradientStop m_maskStopCenter{ nullptr }; + muxc::CompositionColorGradientStop m_maskStopInner{ nullptr }; + muxc::CompositionColorGradientStop m_maskStopOuter{ nullptr }; winrt::Windows::UI::Color m_backgroundColor = FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR; winrt::Windows::UI::Color m_spotlightColor = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR; - winrt::ScalarKeyFrameAnimation m_animation{ nullptr }; + muxc::ScalarKeyFrameAnimation m_animation{ nullptr }; }; template @@ -1047,7 +1216,6 @@ struct GdiCrosshairs : GdiSonar #pragma endregion Super_Sonar_Base_Code - #pragma region Super_Sonar_API CompositionSpotlight* m_sonar = nullptr; @@ -1055,7 +1223,6 @@ void FindMyMouseApplySettings(const FindMyMouseSettings& settings) { if (m_sonar != nullptr) { - Logger::info("Applying settings."); m_sonar->ApplySettings(settings, true); } } @@ -1064,7 +1231,6 @@ void FindMyMouseDisable() { if (m_sonar != nullptr) { - Logger::info("Terminating a sonar instance."); m_sonar->Terminate(); } } @@ -1077,7 +1243,6 @@ bool FindMyMouseIsEnabled() // Based on SuperSonar's original wWinMain. int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings) { - Logger::info("Starting a sonar instance."); if (m_sonar != nullptr) { Logger::error("A sonar instance was still working when trying to start a new one."); @@ -1092,7 +1257,6 @@ int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings) return 0; } m_sonar = &sonar; - Logger::info("Initialized the sonar instance."); InitializeWinhookEventIds(); @@ -1105,7 +1269,6 @@ int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings) DispatchMessage(&msg); } - Logger::info("Sonar message loop ended."); m_sonar = nullptr; return (int)msg.wParam; diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.h b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.h index fb52bf11e5..9efa4dd295 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.h +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.h @@ -11,9 +11,9 @@ enum struct FindMyMouseActivationMethod : int }; constexpr bool FIND_MY_MOUSE_DEFAULT_DO_NOT_ACTIVATE_ON_GAME_MODE = true; -const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 0, 0, 0); -const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255); -constexpr int FIND_MY_MOUSE_DEFAULT_OVERLAY_OPACITY = 50; +// Default colors now include full alpha. Opacity is encoded directly in color alpha (legacy overlay_opacity migrated into A channel) +const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 0, 0, 0); +const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 255, 255, 255); constexpr int FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_RADIUS = 100; constexpr int FIND_MY_MOUSE_DEFAULT_ANIMATION_DURATION_MS = 500; constexpr int FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_INITIAL_ZOOM = 9; @@ -30,7 +30,6 @@ struct FindMyMouseSettings bool doNotActivateOnGameMode = FIND_MY_MOUSE_DEFAULT_DO_NOT_ACTIVATE_ON_GAME_MODE; winrt::Windows::UI::Color backgroundColor = FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR; winrt::Windows::UI::Color spotlightColor = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR; - int overlayOpacity = FIND_MY_MOUSE_DEFAULT_OVERLAY_OPACITY; int spotlightRadius = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_RADIUS; int animationDurationMs = FIND_MY_MOUSE_DEFAULT_ANIMATION_DURATION_MS; int spotlightInitialZoom = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_INITIAL_ZOOM; @@ -44,4 +43,4 @@ int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings); void FindMyMouseDisable(); bool FindMyMouseIsEnabled(); void FindMyMouseApplySettings(const FindMyMouseSettings& settings); -HWND GetSonarHwnd() noexcept; \ No newline at end of file +HWND GetSonarHwnd() noexcept; diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj index 9d4dbd2b28..d127de245e 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj @@ -1,5 +1,12 @@ + + + + + + + 15.0 @@ -7,6 +14,14 @@ Win32Proj FindMyMouse FindMyMouse + true + false + false + false + true + false + + packages.config @@ -30,6 +45,7 @@ + ..\..\..\..\$(Platform)\$(Configuration)\ @@ -79,7 +95,8 @@ - $(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories) + + $(GeneratedFilesDir);$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;$(MSBuildThisFileDirectory)..\..\..\..\src\;$(MSBuildThisFileDirectory)..\..\..\..\src\modules;$(MSBuildThisFileDirectory)..\..\..\..\src\common\Telemetry;%(AdditionalIncludeDirectories) @@ -98,6 +115,7 @@ + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} @@ -112,16 +130,56 @@ + + + + + + + NotUsing + + + + + <_ToDelete Include="$(OutDir)Microsoft.Web.WebView2.Core.dll" /> + + + + - - - + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/MouseUtils/FindMyMouse/dllmain.cpp b/src/modules/MouseUtils/FindMyMouse/dllmain.cpp index 0518f468c2..b7ffb6177a 100644 --- a/src/modules/MouseUtils/FindMyMouse/dllmain.cpp +++ b/src/modules/MouseUtils/FindMyMouse/dllmain.cpp @@ -18,7 +18,7 @@ namespace const wchar_t JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE[] = L"do_not_activate_on_game_mode"; const wchar_t JSON_KEY_BACKGROUND_COLOR[] = L"background_color"; const wchar_t JSON_KEY_SPOTLIGHT_COLOR[] = L"spotlight_color"; - const wchar_t JSON_KEY_OVERLAY_OPACITY[] = L"overlay_opacity"; + const wchar_t JSON_KEY_OVERLAY_OPACITY[] = L"overlay_opacity"; // legacy only (migrated into color alpha) const wchar_t JSON_KEY_SPOTLIGHT_RADIUS[] = L"spotlight_radius"; const wchar_t JSON_KEY_ANIMATION_DURATION_MS[] = L"animation_duration_ms"; const wchar_t JSON_KEY_SPOTLIGHT_INITIAL_ZOOM[] = L"spotlight_initial_zoom"; @@ -204,6 +204,22 @@ void FindMyMouse::init_settings() } } +inline static uint8_t LegacyOpacityToAlpha(int overlayOpacityPercent) +{ + if (overlayOpacityPercent < 0) + { + return 255; // fallback: fully opaque + } + + if (overlayOpacityPercent > 100) + { + overlayOpacityPercent = 100; + } + + // Round to nearest integer (0255) + return static_cast((overlayOpacityPercent * 255 + 50) / 100); +} + void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { auto settingsObject = settings.get_raw_json(); @@ -224,14 +240,13 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) } else { - findMyMouseSettings.activationMethod = static_cast(value); - } + findMyMouseSettings.activationMethod = static_cast(value); + } } else { throw std::runtime_error("Invalid Activation Method value"); } - } catch (...) { @@ -255,19 +270,49 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to get 'do not activate on game mode' setting"); } + // Colors + legacy overlay opacity migration + // Desired behavior: + // - Old schema: colors stored as RGB (no alpha) + separate overlay_opacity (0-100). We should migrate by applying that opacity as alpha. + // - New schema: colors stored as ARGB (alpha embedded). Ignore overlay_opacity even if still present. + int legacyOverlayOpacity = -1; + bool backgroundColorHadExplicitAlpha = false; + bool spotlightColorHadExplicitAlpha = false; try { - // Parse background color - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_BACKGROUND_COLOR); - auto backgroundColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); - uint8_t r, g, b; - if (!checkValidRGB(backgroundColor, &r, &g, &b)) + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_OVERLAY_OPACITY); + int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); + if (value >= 0 && value <= 100) { - Logger::error("Background color RGB value is invalid. Will use default value"); + legacyOverlayOpacity = value; + } + } + catch (...) + { + // overlay_opacity may not exist anymore + } + try + { + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_BACKGROUND_COLOR); + auto backgroundColorStr = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); + uint8_t a = 255, r, g, b; + bool parsed = false; + if (checkValidARGB(backgroundColorStr, &a, &r, &g, &b)) + { + parsed = true; + backgroundColorHadExplicitAlpha = true; // New schema with alpha present + } + else if (checkValidRGB(backgroundColorStr, &r, &g, &b)) + { + a = LegacyOpacityToAlpha(legacyOverlayOpacity); + parsed = true; // Old schema (no alpha component) + } + if (parsed) + { + findMyMouseSettings.backgroundColor = winrt::Windows::UI::ColorHelper::FromArgb(a, r, g, b); } else { - findMyMouseSettings.backgroundColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + Logger::error("Background color value is invalid. Will use default"); } } catch (...) @@ -276,17 +321,27 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) } try { - // Parse spotlight color auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_COLOR); - auto spotlightColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); - uint8_t r, g, b; - if (!checkValidRGB(spotlightColor, &r, &g, &b)) + auto spotlightColorStr = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); + uint8_t a = 255, r, g, b; + bool parsed = false; + if (checkValidARGB(spotlightColorStr, &a, &r, &g, &b)) { - Logger::error("Spotlight color RGB value is invalid. Will use default value"); + parsed = true; + spotlightColorHadExplicitAlpha = true; + } + else if (checkValidRGB(spotlightColorStr, &r, &g, &b)) + { + a = LegacyOpacityToAlpha(legacyOverlayOpacity); + parsed = true; + } + if (parsed) + { + findMyMouseSettings.spotlightColor = winrt::Windows::UI::ColorHelper::FromArgb(a, r, g, b); } else { - findMyMouseSettings.spotlightColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + Logger::error("Spotlight color value is invalid. Will use default"); } } catch (...) @@ -294,24 +349,6 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) Logger::warn("Failed to initialize spotlight color from settings. Will use default value"); } try - { - // Parse Overlay Opacity - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_OVERLAY_OPACITY); - int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - findMyMouseSettings.overlayOpacity = value; - } - else - { - throw std::runtime_error("Invalid Overlay Opacity value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize Overlay Opacity from settings. Will use default value"); - } - try { // Parse Spotlight Radius auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_RADIUS); @@ -492,7 +529,6 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) m_findMyMouseSettings = findMyMouseSettings; } - extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() { return new FindMyMouse(); diff --git a/src/modules/MouseUtils/FindMyMouse/packages.config b/src/modules/MouseUtils/FindMyMouse/packages.config index 09bfc449e2..cff3aa8705 100644 --- a/src/modules/MouseUtils/FindMyMouse/packages.config +++ b/src/modules/MouseUtils/FindMyMouse/packages.config @@ -1,4 +1,12 @@ - + - \ No newline at end of file + + + + + + + + + diff --git a/src/modules/MouseUtils/FindMyMouse/pch.h b/src/modules/MouseUtils/FindMyMouse/pch.h index 26da2455f2..a0a8f1819c 100644 --- a/src/modules/MouseUtils/FindMyMouse/pch.h +++ b/src/modules/MouseUtils/FindMyMouse/pch.h @@ -5,15 +5,22 @@ #include #include #include +// Required for IUnknown and DECLARE_INTERFACE_* used by interop headers +#include #ifdef COMPOSITION -#include #include #include #include -#include +#include +#include +#include #endif #include #include #include + +#ifdef GetCurrentTime +#undef GetCurrentTime +#endif \ No newline at end of file diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp index 61e292d7ee..9b155cb3f3 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp +++ b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp @@ -94,6 +94,21 @@ public: } } + static void SetCrosshairsOrientation(CrosshairsOrientation orientation) + { + if (instance != nullptr) + { + auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue(); + dispatcherQueue.TryEnqueue([orientation]() { + if (instance != nullptr) + { + instance->m_crosshairs_orientation = orientation; + instance->UpdateCrosshairsPosition(); + } + }); + } + } + private: enum class MouseButton { @@ -147,6 +162,7 @@ private: int m_crosshairs_border_size = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_BORDER_SIZE; bool m_crosshairs_is_fixed_length_enabled = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED; int m_crosshairs_fixed_length = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH; + CrosshairsOrientation m_crosshairs_orientation = static_cast(INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION); float m_crosshairs_opacity = max(0.f, min(1.f, (float)INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_OPACITY / 100.0f)); bool m_crosshairs_auto_hide = INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE; }; @@ -286,6 +302,8 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() float halfPixelAdjustment = m_crosshairs_thickness % 2 == 1 ? 0.5f : 0.0f; float borderSizePadding = m_crosshairs_border_size * 2.f; + // Left and Right crosshairs (horizontal line) + if (m_crosshairs_orientation == CrosshairsOrientation::Both || m_crosshairs_orientation == CrosshairsOrientation::HorizontalOnly) { float leftCrosshairsFullScreenLength = ptCursor.x - ptMonitorUpperLeft.x - m_crosshairs_radius + halfPixelAdjustment * 2.f; float leftCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : leftCrosshairsFullScreenLength; @@ -294,9 +312,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() m_left_crosshairs_border.Size({ leftCrosshairsBorderLength, m_crosshairs_thickness + borderSizePadding }); m_left_crosshairs.Offset({ ptCursor.x - m_crosshairs_radius + halfPixelAdjustment * 2.f, ptCursor.y + halfPixelAdjustment, .0f }); m_left_crosshairs.Size({ leftCrosshairsLength, static_cast(m_crosshairs_thickness) }); - } - { float rightCrosshairsFullScreenLength = static_cast(ptMonitorBottomRight.x) - ptCursor.x - m_crosshairs_radius; float rightCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : rightCrosshairsFullScreenLength; float rightCrosshairsBorderLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length + borderSizePadding : rightCrosshairsFullScreenLength + m_crosshairs_border_size; @@ -305,7 +321,17 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() m_right_crosshairs.Offset({ static_cast(ptCursor.x) + m_crosshairs_radius, ptCursor.y + halfPixelAdjustment, .0f }); m_right_crosshairs.Size({ rightCrosshairsLength, static_cast(m_crosshairs_thickness) }); } + else + { + // Hide horizontal crosshairs by setting size to 0 + m_left_crosshairs_border.Size({ 0.0f, 0.0f }); + m_left_crosshairs.Size({ 0.0f, 0.0f }); + m_right_crosshairs_border.Size({ 0.0f, 0.0f }); + m_right_crosshairs.Size({ 0.0f, 0.0f }); + } + // Top and Bottom crosshairs (vertical line) + if (m_crosshairs_orientation == CrosshairsOrientation::Both || m_crosshairs_orientation == CrosshairsOrientation::VerticalOnly) { float topCrosshairsFullScreenLength = ptCursor.y - ptMonitorUpperLeft.y - m_crosshairs_radius + halfPixelAdjustment * 2.f; float topCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : topCrosshairsFullScreenLength; @@ -314,9 +340,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() m_top_crosshairs_border.Size({ m_crosshairs_thickness + borderSizePadding, topCrosshairsBorderLength }); m_top_crosshairs.Offset({ ptCursor.x + halfPixelAdjustment, ptCursor.y - m_crosshairs_radius + halfPixelAdjustment * 2.f, .0f }); m_top_crosshairs.Size({ static_cast(m_crosshairs_thickness), topCrosshairsLength }); - } - { float bottomCrosshairsFullScreenLength = static_cast(ptMonitorBottomRight.y) - ptCursor.y - m_crosshairs_radius; float bottomCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : bottomCrosshairsFullScreenLength; float bottomCrosshairsBorderLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length + borderSizePadding : bottomCrosshairsFullScreenLength + m_crosshairs_border_size; @@ -325,6 +349,14 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() m_bottom_crosshairs.Offset({ ptCursor.x + halfPixelAdjustment, static_cast(ptCursor.y) + m_crosshairs_radius, .0f }); m_bottom_crosshairs.Size({ static_cast(m_crosshairs_thickness), bottomCrosshairsLength }); } + else + { + // Hide vertical crosshairs by setting size to 0 + m_top_crosshairs_border.Size({ 0.0f, 0.0f }); + m_top_crosshairs.Size({ 0.0f, 0.0f }); + m_bottom_crosshairs_border.Size({ 0.0f, 0.0f }); + m_bottom_crosshairs.Size({ 0.0f, 0.0f }); + } } LRESULT CALLBACK InclusiveCrosshairs::MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept @@ -398,6 +430,7 @@ void InclusiveCrosshairs::ApplySettings(InclusiveCrosshairsSettings& settings, b m_crosshairs_auto_hide = settings.crosshairsAutoHide; m_crosshairs_is_fixed_length_enabled = settings.crosshairsIsFixedLengthEnabled; m_crosshairs_fixed_length = settings.crosshairsFixedLength; + m_crosshairs_orientation = settings.crosshairsOrientation; if (applyToRunTimeObjects) { @@ -618,6 +651,11 @@ void InclusiveCrosshairsSetExternalControl(bool enabled) InclusiveCrosshairs::SetExternalControl(enabled); } +void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation) +{ + InclusiveCrosshairs::SetCrosshairsOrientation(orientation); +} + int InclusiveCrosshairsMain(HINSTANCE hInstance, InclusiveCrosshairsSettings& settings) { Logger::info("Starting a crosshairs instance."); diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h index a6618d85bf..4475a397a8 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h +++ b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h @@ -10,8 +10,16 @@ constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_BORDER_SIZE = 1; constexpr bool INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE = false; constexpr bool INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED = false; constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH = 1; +constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION = 0; // 0=Both, 1=Vertical, 2=Horizontal constexpr bool INCLUSIVE_MOUSE_DEFAULT_AUTO_ACTIVATE = false; +enum struct CrosshairsOrientation : int +{ + Both = 0, + VerticalOnly = 1, + HorizontalOnly = 2, +}; + struct InclusiveCrosshairsSettings { winrt::Windows::UI::Color crosshairsColor = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_COLOR; @@ -23,6 +31,7 @@ struct InclusiveCrosshairsSettings bool crosshairsAutoHide = INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE; bool crosshairsIsFixedLengthEnabled = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED; int crosshairsFixedLength = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH; + CrosshairsOrientation crosshairsOrientation = static_cast(INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION); bool autoActivate = INCLUSIVE_MOUSE_DEFAULT_AUTO_ACTIVATE; }; @@ -35,3 +44,4 @@ void InclusiveCrosshairsRequestUpdatePosition(); void InclusiveCrosshairsEnsureOn(); void InclusiveCrosshairsEnsureOff(); void InclusiveCrosshairsSetExternalControl(bool enabled); +void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation); diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj index 7da54a51e9..58668c663f 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj +++ b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj @@ -80,7 +80,7 @@ - $(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories) + ..\..\..\;..\..\..\modules;..\..\..\common\Telemetry;%(AdditionalIncludeDirectories) diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp index 3dcee0d6a4..b460e29643 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp +++ b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp @@ -8,11 +8,15 @@ #include #include #include +#include extern void InclusiveCrosshairsRequestUpdatePosition(); extern void InclusiveCrosshairsEnsureOn(); extern void InclusiveCrosshairsEnsureOff(); extern void InclusiveCrosshairsSetExternalControl(bool enabled); +extern void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation); +extern bool InclusiveCrosshairsIsEnabled(); +extern void InclusiveCrosshairsSwitch(); // Non-Localizable strings namespace @@ -30,6 +34,7 @@ namespace const wchar_t JSON_KEY_CROSSHAIRS_AUTO_HIDE[] = L"crosshairs_auto_hide"; const wchar_t JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED[] = L"crosshairs_is_fixed_length_enabled"; const wchar_t JSON_KEY_CROSSHAIRS_FIXED_LENGTH[] = L"crosshairs_fixed_length"; + const wchar_t JSON_KEY_CROSSHAIRS_ORIENTATION[] = L"crosshairs_orientation"; const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; const wchar_t JSON_KEY_GLIDE_TRAVEL_SPEED[] = L"gliding_travel_speed"; const wchar_t JSON_KEY_GLIDE_DELAY_SPEED[] = L"gliding_delay_speed"; @@ -62,6 +67,9 @@ const static wchar_t* MODULE_NAME = L"MousePointerCrosshairs"; // Add a description that will we shown in the module settings page. const static wchar_t* MODULE_DESC = L""; +class MousePointerCrosshairs; // fwd +static std::atomic g_instance{ nullptr }; // for hook callback + // Implement the PowerToy Module Interface and all the required methods. class MousePointerCrosshairs : public PowertoyModuleIface { @@ -70,8 +78,11 @@ private: bool m_enabled = false; // Additional hotkeys (legacy API) to support multiple shortcuts - Hotkey m_activationHotkey{}; // Crosshairs toggle - Hotkey m_glidingHotkey{}; // Gliding cursor state machine + Hotkey m_activationHotkey{}; // Crosshairs toggle + Hotkey m_glidingHotkey{}; // Gliding cursor state machine + + // Low-level keyboard hook (Escape to cancel gliding) + HHOOK m_keyboardHook = nullptr; // Shared state for worker threads (decoupled from this lifetime) struct State @@ -84,7 +95,7 @@ private: int currentYPos{ 0 }; int currentXSpeed{ 0 }; // pixels per base window int currentYSpeed{ 0 }; // pixels per base window - int xPosSnapshot{ 0 }; // xPos captured at end of horizontal scan + int xPosSnapshot{ 0 }; // xPos captured at end of horizontal scan // Fractional accumulators to spread movement across 10ms ticks double xFraction{ 0.0 }; @@ -92,9 +103,9 @@ private: // Speeds represent pixels per 200ms (min 5, max 60 enforced by UI/settings) int fastHSpeed{ 30 }; // pixels per base window - int slowHSpeed{ 5 }; // pixels per base window + int slowHSpeed{ 5 }; // pixels per base window int fastVSpeed{ 30 }; // pixels per base window - int slowVSpeed{ 5 }; // pixels per base window + int slowVSpeed{ 5 }; // pixels per base window }; std::shared_ptr m_state; @@ -120,13 +131,16 @@ public: LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::mousePointerCrosshairsLoggerName); m_state = std::make_shared(); init_settings(); + g_instance.store(this, std::memory_order_release); }; // Destroy the powertoy and free memory virtual void destroy() override { + UninstallKeyboardHook(); StopXTimer(); StopYTimer(); + g_instance.store(nullptr, std::memory_order_release); // Release shared state so worker threads (if any) exit when weak_ptr lock fails m_state.reset(); delete this; @@ -196,6 +210,7 @@ public: { m_enabled = false; Trace::EnableMousePointerCrosshairs(false); + UninstallKeyboardHook(); StopXTimer(); StopYTimer(); m_glideState = 0; @@ -220,7 +235,7 @@ public: if (buffer && buffer_size >= 2) { buffer[0] = m_activationHotkey; // Crosshairs toggle - buffer[1] = m_glidingHotkey; // Gliding cursor toggle + buffer[1] = m_glidingHotkey; // Gliding cursor toggle } return 2; } @@ -232,12 +247,19 @@ public: return false; } - if (hotkeyId == 0) + if (hotkeyId == 0) // Crosshairs activation { + // If gliding cursor is active, cancel it and activate crosshairs + if (m_glideState.load() != 0) + { + CancelGliding(true /*activateCrosshairs*/); + return true; + } + // Otherwise, normal crosshairs toggle InclusiveCrosshairsSwitch(); return true; } - if (hotkeyId == 1) + if (hotkeyId == 1) // Gliding cursor activation { HandleGlidingHotkey(); return true; @@ -256,6 +278,46 @@ private: SendInput(2, inputs, sizeof(INPUT)); } + // Cancel gliding with option to activate crosshairs in user's preferred orientation + void CancelGliding(bool activateCrosshairs) + { + int state = m_glideState.load(); + if (state == 0) + { + return; // nothing to cancel + } + + // Stop all gliding operations + StopXTimer(); + StopYTimer(); + m_glideState = 0; + UninstallKeyboardHook(); + + // Reset crosshairs control and restore user settings + InclusiveCrosshairsSetExternalControl(false); + InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation); + + if (activateCrosshairs) + { + // User is switching to crosshairs mode - enable with their settings + InclusiveCrosshairsEnsureOn(); + } + else + { + // User canceled (Escape) - turn off crosshairs completely + InclusiveCrosshairsEnsureOff(); + } + + // Reset gliding state + if (auto s = m_state) + { + s->xFraction = 0.0; + s->yFraction = 0.0; + } + + Logger::debug("Gliding cursor cancelled (activateCrosshairs={})", activateCrosshairs ? 1 : 0); + } + // Stateless helpers operating on shared State static void PositionCursorX(const std::shared_ptr& s) { @@ -392,17 +454,22 @@ private: { return; } - // Simulate the AHK state machine + int state = m_glideState.load(); switch (state) { - case 0: + case 0: // Starting gliding { - // Ensure crosshairs on (do not toggle off if already on) - InclusiveCrosshairsEnsureOn(); - // Disable internal mouse hook so we control position updates explicitly + // Install keyboard hook for Escape cancellation + InstallKeyboardHook(); + + // Force crosshairs visible in BOTH orientation for gliding, regardless of user setting + // Set external control before enabling to prevent internal movement hook from attaching InclusiveCrosshairsSetExternalControl(true); + InclusiveCrosshairsSetOrientation(CrosshairsOrientation::Both); + InclusiveCrosshairsEnsureOn(); // Always ensure they are visible + // Initialize gliding state s->currentXPos = 0; s->currentXSpeed = s->fastHSpeed; s->xFraction = 0.0; @@ -410,20 +477,17 @@ private: int y = GetSystemMetrics(SM_CYVIRTUALSCREEN) / 2; SetCursorPos(0, y); InclusiveCrosshairsRequestUpdatePosition(); + m_glideState = 1; StartXTimer(); break; } - case 1: - { - // Slow horizontal + case 1: // Slow horizontal s->currentXSpeed = s->slowHSpeed; m_glideState = 2; break; - } - case 2: + case 2: // Switch to vertical fast { - // Stop horizontal, start vertical (fast) StopXTimer(); s->currentYSpeed = s->fastVSpeed; s->currentYPos = 0; @@ -434,29 +498,78 @@ private: StartYTimer(); break; } - case 3: - { - // Slow vertical + case 3: // Slow vertical s->currentYSpeed = s->slowVSpeed; m_glideState = 4; break; - } - case 4: + case 4: // Finalize (click and end) default: { - // Stop vertical, click, turn crosshairs off, re-enable internal tracking, reset state + // Complete the gliding sequence StopYTimer(); m_glideState = 0; LeftClick(); - InclusiveCrosshairsEnsureOff(); + + // Restore normal crosshairs operation and turn them off InclusiveCrosshairsSetExternalControl(false); - s->xFraction = 0.0; - s->yFraction = 0.0; + InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation); + InclusiveCrosshairsEnsureOff(); + + UninstallKeyboardHook(); + + // Reset state + if (auto sp = m_state) + { + sp->xFraction = 0.0; + sp->yFraction = 0.0; + } break; } } } + // Low-level keyboard hook for Escape cancellation + static LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) + { + if (nCode == HC_ACTION) + { + const KBDLLHOOKSTRUCT* kb = reinterpret_cast(lParam); + if (kb && kb->vkCode == VK_ESCAPE && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)) + { + if (auto inst = g_instance.load(std::memory_order_acquire)) + { + if (inst->m_enabled && inst->m_glideState.load() != 0) + { + inst->CancelGliding(false); // Escape cancels without activating crosshairs + } + } + } + } + return CallNextHookEx(nullptr, nCode, wParam, lParam); + } + + void InstallKeyboardHook() + { + if (m_keyboardHook) + { + return; // already installed + } + m_keyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKeyboardProc, m_hModule, 0); + if (!m_keyboardHook) + { + Logger::error("Failed to install low-level keyboard hook for MousePointerCrosshairs (Escape cancel). GetLastError={}.", GetLastError()); + } + } + + void UninstallKeyboardHook() + { + if (m_keyboardHook) + { + UnhookWindowsHookEx(m_keyboardHook); + m_keyboardHook = nullptr; + } + } + // Load the settings file. void init_settings() { @@ -475,264 +588,287 @@ private: void parse_settings(PowerToysSettings::PowerToyValues& settings) { - // TODO: refactor to use common/utils/json.h instead + // Refactored JSON parsing: uses inline try-catch blocks for each property for clarity and error handling auto settingsObject = settings.get_raw_json(); InclusiveCrosshairsSettings inclusiveCrosshairsSettings; + if (settingsObject.GetView().Size()) { try { - // Parse primary activation HotKey (for centralized hook) - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); - auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + + // Parse activation hotkey + try + { + auto jsonHotkeyObject = propertiesObject.GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonHotkeyObject); + m_activationHotkey.win = hotkey.win_pressed(); + m_activationHotkey.ctrl = hotkey.ctrl_pressed(); + m_activationHotkey.shift = hotkey.shift_pressed(); + m_activationHotkey.alt = hotkey.alt_pressed(); + m_activationHotkey.key = static_cast(hotkey.get_code()); + } + catch (...) + { + Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut"); + } - // Map to legacy Hotkey for multi-hotkey API - m_activationHotkey.win = hotkey.win_pressed(); - m_activationHotkey.ctrl = hotkey.ctrl_pressed(); - m_activationHotkey.shift = hotkey.shift_pressed(); - m_activationHotkey.alt = hotkey.alt_pressed(); - m_activationHotkey.key = static_cast(hotkey.get_code()); - } - catch (...) - { - Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut"); - } - try - { - // Parse Gliding Cursor HotKey - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDING_ACTIVATION_SHORTCUT); - auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); - m_glidingHotkey.win = hotkey.win_pressed(); - m_glidingHotkey.ctrl = hotkey.ctrl_pressed(); - m_glidingHotkey.shift = hotkey.shift_pressed(); - m_glidingHotkey.alt = hotkey.alt_pressed(); - m_glidingHotkey.key = static_cast(hotkey.get_code()); - } - catch (...) - { - // note that this is also defined in src\settings-ui\Settings.UI.Library\MousePointerCrosshairsProperties.cs, DefaultGlidingCursorActivationShortcut - // both need to be kept in sync! - Logger::warn("Failed to initialize Gliding Cursor activation shortcut. Using default Win+Alt+."); - m_glidingHotkey.win = true; - m_glidingHotkey.alt = true; - m_glidingHotkey.ctrl = false; - m_glidingHotkey.shift = false; - m_glidingHotkey.key = VK_OEM_PERIOD; - } - try - { - // Parse Opacity - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_OPACITY); - int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) + // Parse gliding cursor hotkey + try { - inclusiveCrosshairsSettings.crosshairsOpacity = value; + auto jsonHotkeyObject = propertiesObject.GetNamedObject(JSON_KEY_GLIDING_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonHotkeyObject); + m_glidingHotkey.win = hotkey.win_pressed(); + m_glidingHotkey.ctrl = hotkey.ctrl_pressed(); + m_glidingHotkey.shift = hotkey.shift_pressed(); + m_glidingHotkey.alt = hotkey.alt_pressed(); + m_glidingHotkey.key = static_cast(hotkey.get_code()); } - else + catch (...) { - throw std::runtime_error("Invalid Opacity value"); + Logger::warn("Failed to initialize Gliding Cursor activation shortcut. Using default Win+Alt+."); + m_glidingHotkey.win = true; + m_glidingHotkey.alt = true; + m_glidingHotkey.ctrl = false; + m_glidingHotkey.shift = false; + m_glidingHotkey.key = VK_OEM_PERIOD; + } + + // Parse individual properties with error handling and defaults + try + { + if (propertiesObject.HasKey(L"crosshairs_opacity")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_opacity"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsOpacity = static_cast(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_radius")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_radius"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsRadius = static_cast(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_thickness")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_thickness"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsThickness = static_cast(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_border_size")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_border_size"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsBorderSize = static_cast(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_fixed_length")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_fixed_length"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsFixedLength = static_cast(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_auto_hide")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_auto_hide"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsAutoHide = propertyObj.GetNamedBoolean(L"value"); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_is_fixed_length_enabled")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_is_fixed_length_enabled"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsIsFixedLengthEnabled = propertyObj.GetNamedBoolean(L"value"); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"auto_activate")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"auto_activate"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.autoActivate = propertyObj.GetNamedBoolean(L"value"); + } + } + } + catch (...) { /* Use default value */ } + + // Parse orientation with validation - this fixes the original issue! + try + { + if (propertiesObject.HasKey(L"crosshairs_orientation")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_orientation"); + if (propertyObj.HasKey(L"value")) + { + int orientationValue = static_cast(propertyObj.GetNamedNumber(L"value")); + if (orientationValue >= 0 && orientationValue <= 2) + { + inclusiveCrosshairsSettings.crosshairsOrientation = static_cast(orientationValue); + } + } + } + } + catch (...) { /* Use default value (Both = 0) */ } + + // Parse colors with validation + try + { + if (propertiesObject.HasKey(L"crosshairs_color")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_color"); + if (propertyObj.HasKey(L"value")) + { + std::wstring crosshairsColorValue = std::wstring(propertyObj.GetNamedString(L"value").c_str()); + uint8_t r, g, b; + if (checkValidRGB(crosshairsColorValue, &r, &g, &b)) + { + inclusiveCrosshairsSettings.crosshairsColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + } + } + } + } + catch (...) { /* Use default color */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_border_color")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_border_color"); + if (propertyObj.HasKey(L"value")) + { + std::wstring borderColorValue = std::wstring(propertyObj.GetNamedString(L"value").c_str()); + uint8_t r, g, b; + if (checkValidRGB(borderColorValue, &r, &g, &b)) + { + inclusiveCrosshairsSettings.crosshairsBorderColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + } + } + } + } + catch (...) { /* Use default border color */ } + + // Parse speed settings with validation + try + { + if (propertiesObject.HasKey(L"gliding_travel_speed")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"gliding_travel_speed"); + if (propertyObj.HasKey(L"value") && m_state) + { + int travelSpeedValue = static_cast(propertyObj.GetNamedNumber(L"value")); + if (travelSpeedValue >= 5 && travelSpeedValue <= 60) + { + m_state->fastHSpeed = travelSpeedValue; + m_state->fastVSpeed = travelSpeedValue; + } + else + { + // Clamp to valid range + int clampedValue = travelSpeedValue; + if (clampedValue < 5) clampedValue = 5; + if (clampedValue > 60) clampedValue = 60; + m_state->fastHSpeed = clampedValue; + m_state->fastVSpeed = clampedValue; + Logger::warn("Travel speed value out of range, clamped to valid range"); + } + } + } + } + catch (...) + { + if (m_state) + { + m_state->fastHSpeed = 25; + m_state->fastVSpeed = 25; + } + } + + try + { + if (propertiesObject.HasKey(L"gliding_delay_speed")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"gliding_delay_speed"); + if (propertyObj.HasKey(L"value") && m_state) + { + int delaySpeedValue = static_cast(propertyObj.GetNamedNumber(L"value")); + if (delaySpeedValue >= 5 && delaySpeedValue <= 60) + { + m_state->slowHSpeed = delaySpeedValue; + m_state->slowVSpeed = delaySpeedValue; + } + else + { + // Clamp to valid range + int clampedValue = delaySpeedValue; + if (clampedValue < 5) clampedValue = 5; + if (clampedValue > 60) clampedValue = 60; + m_state->slowHSpeed = clampedValue; + m_state->slowVSpeed = clampedValue; + Logger::warn("Delay speed value out of range, clamped to valid range"); + } + } + } + } + catch (...) + { + if (m_state) + { + m_state->slowHSpeed = 5; + m_state->slowVSpeed = 5; + } } } catch (...) { - Logger::warn("Failed to initialize Opacity from settings. Will use default value"); - } - try - { - // Parse crosshairs color - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_COLOR); - auto crosshairsColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); - uint8_t r, g, b; - if (!checkValidRGB(crosshairsColor, &r, &g, &b)) - { - Logger::error("Crosshairs color RGB value is invalid. Will use default value"); - } - else - { - inclusiveCrosshairsSettings.crosshairsColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); - } - } - catch (...) - { - Logger::warn("Failed to initialize crosshairs color from settings. Will use default value"); - } - try - { - // Parse Radius - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_RADIUS); - int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - inclusiveCrosshairsSettings.crosshairsRadius = value; - } - else - { - throw std::runtime_error("Invalid Radius value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize Radius from settings. Will use default value"); - } - try - { - // Parse Thickness - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_THICKNESS); - int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - inclusiveCrosshairsSettings.crosshairsThickness = value; - } - else - { - throw std::runtime_error("Invalid Thickness value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize Thickness from settings. Will use default value"); - } - try - { - // Parse crosshairs border color - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_COLOR); - auto crosshairsBorderColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); - uint8_t r, g, b; - if (!checkValidRGB(crosshairsBorderColor, &r, &g, &b)) - { - Logger::error("Crosshairs border color RGB value is invalid. Will use default value"); - } - else - { - inclusiveCrosshairsSettings.crosshairsBorderColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); - } - } - catch (...) - { - Logger::warn("Failed to initialize crosshairs border color from settings. Will use default value"); - } - try - { - // Parse border size - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_SIZE); - int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - inclusiveCrosshairsSettings.crosshairsBorderSize = value; - } - else - { - throw std::runtime_error("Invalid Border Color value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize border color from settings. Will use default value"); - } - try - { - // Parse auto hide - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_AUTO_HIDE); - inclusiveCrosshairsSettings.crosshairsAutoHide = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); - } - catch (...) - { - Logger::warn("Failed to initialize auto hide from settings. Will use default value"); - } - try - { - // Parse whether the fixed length is enabled - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED); - bool value = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); - inclusiveCrosshairsSettings.crosshairsIsFixedLengthEnabled = value; - } - catch (...) - { - Logger::warn("Failed to initialize fixed length enabled from settings. Will use default value"); - } - try - { - // Parse fixed length - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_FIXED_LENGTH); - int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - inclusiveCrosshairsSettings.crosshairsFixedLength = value; - } - else - { - throw std::runtime_error("Invalid Fixed Length value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize fixed length from settings. Will use default value"); - } - try - { - // Parse auto activate - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE); - inclusiveCrosshairsSettings.autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); - } - catch (...) - { - Logger::warn("Failed to initialize auto activate from settings. Will use default value"); - } - try - { - // Parse Travel speed (fast speed mapping) - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDE_TRAVEL_SPEED); - int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 5 && value <= 60) - { - m_state->fastHSpeed = value; - m_state->fastVSpeed = value; - } - else if (value < 5) - { - m_state->fastHSpeed = 5; m_state->fastVSpeed = 5; - } - else - { - m_state->fastHSpeed = 60; m_state->fastVSpeed = 60; - } - } - catch (...) - { - Logger::warn("Failed to initialize gliding travel speed from settings. Using default 25."); - if (m_state) - { - m_state->fastHSpeed = 25; - m_state->fastVSpeed = 25; - } - } - try - { - // Parse Delay speed (slow speed mapping) - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDE_DELAY_SPEED); - int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 5 && value <= 60) - { - m_state->slowHSpeed = value; - m_state->slowVSpeed = value; - } - else if (value < 5) - { - m_state->slowHSpeed = 5; m_state->slowVSpeed = 5; - } - else - { - m_state->slowHSpeed = 60; m_state->slowVSpeed = 60; - } - } - catch (...) - { - Logger::warn("Failed to initialize gliding delay speed from settings. Using default 5."); - if (m_state) - { - m_state->slowHSpeed = 5; - m_state->slowVSpeed = 5; - } + Logger::warn("Error parsing some MousePointerCrosshairs properties. Using defaults for failed properties."); } } else @@ -740,6 +876,7 @@ private: Logger::info("Mouse Pointer Crosshairs settings are empty"); } + // Set default hotkeys if not configured if (m_activationHotkey.key == 0) { m_activationHotkey.win = true; @@ -756,6 +893,7 @@ private: m_glidingHotkey.shift = false; m_glidingHotkey.key = VK_OEM_PERIOD; } + m_inclusiveCrosshairsSettings = inclusiveCrosshairsSettings; } }; diff --git a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs index 0877101d60..7cad62decb 100644 --- a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs +++ b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs @@ -49,23 +49,23 @@ namespace MouseUtils.UITests settings.BackgroundColor = "000000"; settings.SpotlightColor = "FFFFFF"; - var foundCustom = this.Find("Find My Mouse"); + var foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); Assert.IsNotNull(foundCustom); if (CheckAnimationEnable(ref foundCustom)) { - foundCustom = this.Find("Find My Mouse"); + foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); } if (foundCustom != null) { - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); Assert.IsNotNull(foundCustom, "Find My Mouse group not found."); SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); - var excludedApps = foundCustom.Find("Excluded apps"); + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); if (excludedApps != null) { excludedApps.Click(); @@ -115,23 +115,23 @@ namespace MouseUtils.UITests settings.BackgroundColor = "FF0000"; settings.SpotlightColor = "0000FF"; - var foundCustom = this.Find("Find My Mouse"); + var foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); Assert.IsNotNull(foundCustom); if (CheckAnimationEnable(ref foundCustom)) { - foundCustom = this.Find("Find My Mouse"); + foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); } if (foundCustom != null) { - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); Assert.IsNotNull(foundCustom, "Find My Mouse group not found."); SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); - var excludedApps = foundCustom.Find("Excluded apps"); + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); if (excludedApps != null) { excludedApps.Click(); @@ -170,27 +170,27 @@ namespace MouseUtils.UITests settings.AnimationDuration = "0"; settings.BackgroundColor = "000000"; settings.SpotlightColor = "FFFFFF"; - var foundCustom = this.Find("Find My Mouse"); + var foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); Assert.IsNotNull(foundCustom); if (CheckAnimationEnable(ref foundCustom)) { - foundCustom = this.Find("Find My Mouse"); + foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); } if (foundCustom != null) { - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); - foundCustom.Find("Enable Find My Mouse").Toggle(false); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); Assert.IsNotNull(foundCustom); SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); - var excludedApps = foundCustom.Find("Excluded apps"); + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); if (excludedApps != null) { excludedApps.Click(); @@ -212,14 +212,14 @@ namespace MouseUtils.UITests VerifySpotlightAppears(ref settings); // [Test Case] Disable FindMyMouse. Verify the overlay no longer appears when you press Left Ctrl twice - foundCustom.Find("Enable Find My Mouse").Toggle(false); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); Task.Delay(1000).Wait(); ActivateSpotlight(ref settings); VerifySpotlightDisappears(ref settings); // [Test Case] Press Left Ctrl twice and verify the overlay appears - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); Task.Delay(2000).Wait(); ActivateSpotlight(ref settings); VerifySpotlightAppears(ref settings); @@ -240,27 +240,27 @@ namespace MouseUtils.UITests settings.AnimationDuration = "0"; settings.BackgroundColor = "000000"; settings.SpotlightColor = "FFFFFF"; - var foundCustom = this.Find("Find My Mouse"); + var foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); Assert.IsNotNull(foundCustom); if (CheckAnimationEnable(ref foundCustom)) { - foundCustom = this.Find("Find My Mouse"); + foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); } if (foundCustom != null) { - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); - foundCustom.Find("Enable Find My Mouse").Toggle(false); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); Assert.IsNotNull(foundCustom); SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); - var excludedApps = foundCustom.Find("Excluded apps"); + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); if (excludedApps != null) { excludedApps.Click(); @@ -282,14 +282,14 @@ namespace MouseUtils.UITests VerifySpotlightAppears(ref settings); // [Test Case] Disable FindMyMouse. Verify the overlay no longer appears when you press Left Ctrl twice - foundCustom.Find("Enable Find My Mouse").Toggle(false); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); Task.Delay(1000).Wait(); ActivateSpotlight(ref settings); VerifySpotlightDisappears(ref settings); // [Test Case] Press Left Ctrl twice and verify the overlay appears - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); Task.Delay(2000).Wait(); ActivateSpotlight(ref settings); VerifySpotlightAppears(ref settings); @@ -310,17 +310,17 @@ namespace MouseUtils.UITests settings.AnimationDuration = "0"; settings.BackgroundColor = "000000"; settings.SpotlightColor = "FFFFFF"; - var foundCustom = this.Find("Find My Mouse"); + var foundCustom = this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); if (foundCustom != null) { - foundCustom.Find("Enable Find My Mouse").Toggle(true); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); - // foundCustom.Find("Enable Find My Mouse").Toggle(false); + // foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); Assert.IsNotNull(foundCustom, "Find My Mouse group not found."); // SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); - var excludedApps = foundCustom.Find("Excluded apps"); + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); if (excludedApps != null) { excludedApps.Click(); @@ -340,7 +340,7 @@ namespace MouseUtils.UITests // VerifySpotlightSettings(ref settings); // [Test Case] Disable FindMyMouse. Verify the overlay no longer appears when you press Left Ctrl twice - foundCustom.Find("Enable Find My Mouse").Toggle(false); + foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); Task.Delay(2000).Wait(); Session.SendKey(Key.LCtrl, 0, 0); Task.Delay(100).Wait(); @@ -382,9 +382,6 @@ namespace MouseUtils.UITests var colorBackground = this.GetPixelColorString(location.Item1 + radius + 50, location.Item2 + radius + 50); Assert.AreEqual("#" + settings.BackgroundColor, colorBackground); - - var colorBackground2 = this.GetPixelColorString(location.Item1 + radius + 100, location.Item2 + radius + 100); - Assert.AreEqual("#" + settings.BackgroundColor, colorBackground2); } private void ActivateSpotlight(ref FindMyMouseSettings settings) @@ -427,7 +424,7 @@ namespace MouseUtils.UITests private void SetFindMyMouseActivationMethod(ref Custom? foundCustom, string method) { Assert.IsNotNull(foundCustom); - var groupActivation = foundCustom.Find("Activation method"); + var groupActivation = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseActivationMethod)); if (groupActivation != null) { groupActivation.Click(); @@ -456,17 +453,17 @@ namespace MouseUtils.UITests private void SetFindMyMouseAppearanceBehavior(ref Custom foundCustom, ref FindMyMouseSettings settings) { Assert.IsNotNull(foundCustom); - var groupAppearanceBehavior = foundCustom.Find("Appearance & behavior"); + var groupAppearanceBehavior = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseAppearanceBehavior)); if (groupAppearanceBehavior != null) { // groupAppearanceBehavior.Click(); - if (foundCustom.FindAll("Overlay opacity (%)").Count == 0) + if (foundCustom.FindAll(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseOverlayOpacity)).Count == 0) { groupAppearanceBehavior.Click(); } // Set the BackGround color - var backgroundColor = foundCustom.Find("Background color"); + var backgroundColor = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseBackgroundColor)); Assert.IsNotNull(backgroundColor); var button = backgroundColor.Find @@ -77,31 +101,23 @@ + - + Visibility="{x:Bind IsLink, Mode=OneWay}"> + + - + + Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), FallbackValue=Collapsed}" /> - + - - + + @@ -170,9 +186,12 @@ + VerticalAlignment="Stretch" + BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderThickness="0,0,0,1"> @@ -327,11 +346,6 @@ x:Name="FiltersDropDown" HorizontalAlignment="Right" CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" /> - - - - - @@ -354,11 +368,7 @@ - + @@ -367,6 +377,9 @@ @@ -427,12 +440,14 @@ TextWrapping="WrapWholeWords" Visibility="{x:Bind ViewModel.Details.Title, Converter={StaticResource StringNotEmptyToVisibilityConverter}, Mode=OneWay}" /> - + Text="{x:Bind ViewModel.Details.Body, Mode=OneWay}" + UseEmphasisExtras="True" + UsePipeTables="True" /> - + @@ -517,6 +536,26 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 2c8788faa8..e51597d268 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -3,12 +3,15 @@ // See the LICENSE file in the project root for more information. using System.ComponentModel; +using System.Globalization; +using System.Text; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; using ManagedCommon; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.UI.Events; +using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.Settings; using Microsoft.CmdPal.UI.ViewModels; @@ -17,9 +20,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Dispatching; using Microsoft.UI.Input; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media.Animation; +using Windows.UI.Core; using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; using VirtualKey = Windows.System.VirtualKey; @@ -42,7 +47,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, IRecipient, IRecipient, IRecipient, - INotifyPropertyChanged + INotifyPropertyChanged, + IDisposable { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -55,8 +61,13 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, private readonly ToastWindow _toast = new(); + private readonly CompositeFormat _pageNavigatedAnnouncement; + private SettingsWindow? _settingsWindow; + private CancellationTokenSource? _focusAfterLoadedCts; + private WeakReference? _lastNavigatedPageRef; + public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService()!; public event PropertyChangedEventHandler? PropertyChanged; @@ -84,9 +95,25 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, WeakReferenceMessenger.Default.Register(this); AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true); + AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false); AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true); - RootFrame.Navigate(typeof(LoadingPage), ViewModel); + RootFrame.Navigate(typeof(LoadingPage), new AsyncNavigationRequest(ViewModel, CancellationToken.None)); + + var pageAnnouncementFormat = ResourceLoaderInstance.GetString("ScreenReader_Announcement_NavigatedToPage0"); + _pageNavigatedAnnouncement = CompositeFormat.Parse(pageAnnouncementFormat); + } + + /// + /// Gets the default page animation, depending on the settings + /// + private NavigationTransitionInfo DefaultPageAnimation + { + get + { + var settings = App.Current.Services.GetService()!; + return settings.DisableAnimations ? _noAnimation : _slideRightTransition; + } } public void Receive(NavigateBackMessage message) @@ -129,13 +156,10 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, ContentPageViewModel => typeof(ContentPage), _ => throw new NotSupportedException(), }, - message.Page, - message.WithAnimation ? _slideRightTransition : _noAnimation); + new AsyncNavigationRequest(message.Page, message.CancellationToken), + message.WithAnimation ? DefaultPageAnimation : _noAnimation); - PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth)); - - // Refocus on the Search for continual typing on the next search request - SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth, message.Page.Id)); if (!ViewModel.IsNested) { @@ -247,32 +271,46 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, public void Receive(ShowDetailsMessage message) { - // TERRIBLE HACK TODO GH #245 - // There's weird wacky bugs with debounce currently. - if (!ViewModel.IsDetailsVisible) + if (ViewModel is not null && + ViewModel.CurrentPage is not null) { - ViewModel.Details = message.Details; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); - ViewModel.IsDetailsVisible = true; - return; + if (ViewModel.CurrentPage.PageContext.TryGetTarget(out var pageContext)) + { + Task.Factory.StartNew( + () => + { + // TERRIBLE HACK TODO GH #245 + // There's weird wacky bugs with debounce currently. + if (!ViewModel.IsDetailsVisible) + { + ViewModel.Details = message.Details; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); + ViewModel.IsDetailsVisible = true; + return; + } + + // GH #322: + // For inexplicable reasons, if you try to change the details too fast, + // we'll explode. This seemingly only happens if you change the details + // while we're also scrolling a new list view item into view. + _debounceTimer.Debounce( + () => + { + ViewModel.Details = message.Details; + + // Trigger a re-evaluation of whether we have a hero image based on + // the current theme + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); + }, + interval: TimeSpan.FromMilliseconds(50), + immediate: ViewModel.IsDetailsVisible == false); + ViewModel.IsDetailsVisible = true; + }, + CancellationToken.None, + TaskCreationOptions.None, + pageContext.Scheduler); + } } - - // GH #322: - // For inexplicable reasons, if you try to change the details too fast, - // we'll explode. This seemingly only happens if you change the details - // while we're also scrolling a new list view item into view. - _debounceTimer.Debounce( - () => - { - ViewModel.Details = message.Details; - - // Trigger a re-evaluation of whether we have a hero image based on - // the current theme - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); - }, - interval: TimeSpan.FromMilliseconds(50), - immediate: ViewModel.IsDetailsVisible == false); - ViewModel.IsDetailsVisible = true; } public void Receive(HideDetailsMessage message) => HideDetails(); @@ -368,6 +406,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, { HideDetails(); + ViewModel.CancelNavigation(); + // Note: That we restore the VM state below in RootFrame_Navigated call back after this occurs. // In the future, we may want to manage the back stack ourselves vs. relying on Frame // We could replace Frame with a ContentPresenter, but then have to manage transition animations ourselves. @@ -410,7 +450,15 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, { while (RootFrame.CanGoBack) { - GoBack(withAnimation, focusSearch); + // don't focus on each step, just at the end + GoBack(withAnimation, focusSearch: false); + } + + // focus search box, even if we were already home + if (focusSearch) + { + SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + SearchBox.SelectSearch(); } } @@ -421,12 +469,141 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, // This listens to the root frame to ensure that we also track the content's page VM as well that we passed as a parameter. // This is currently used for both forward and backward navigation. // As when we go back that we restore ourselves to the proper state within our VM - if (e.Parameter is PageViewModel page) + if (e.Parameter is AsyncNavigationRequest request) { - // Note, this shortcuts and fights a bit with our LoadPageViewModel above, but we want to better fast display and incrementally load anyway - // We just need to reconcile our loading systems a bit more in the future. - ViewModel.CurrentPage = page; + if (request.NavigationToken.IsCancellationRequested && e.NavigationMode is not (Microsoft.UI.Xaml.Navigation.NavigationMode.Back or Microsoft.UI.Xaml.Navigation.NavigationMode.Forward)) + { + return; + } + + switch (request.TargetViewModel) + { + case PageViewModel pageViewModel: + ViewModel.CurrentPage = pageViewModel; + break; + case ShellViewModel: + // This one is an exception, for now (LoadingPage is tied to ShellViewModel, + // but ShellViewModel is not PageViewModel. + ViewModel.CurrentPage = ViewModel.NullPage; + break; + default: + ViewModel.CurrentPage = ViewModel.NullPage; + Logger.LogWarning($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(PageViewModel)}"); + break; + } } + else + { + Logger.LogWarning("Unrecognized target for shell navigation: " + e.Parameter); + } + + if (e.Content is Page element) + { + _lastNavigatedPageRef = new WeakReference(element); + element.Loaded += FocusAfterLoaded; + } + } + + private void FocusAfterLoaded(object sender, RoutedEventArgs e) + { + var page = (Page)sender; + page.Loaded -= FocusAfterLoaded; + + // Only handle focus for the latest navigated page + if (_lastNavigatedPageRef is null || !_lastNavigatedPageRef.TryGetTarget(out var last) || !ReferenceEquals(page, last)) + { + return; + } + + // Cancel any previous pending focus work + _focusAfterLoadedCts?.Cancel(); + _focusAfterLoadedCts?.Dispose(); + _focusAfterLoadedCts = new CancellationTokenSource(); + var token = _focusAfterLoadedCts.Token; + + AnnounceNavigationToPage(page); + + var shouldSearchBoxBeVisible = ViewModel.CurrentPage?.HasSearchBox ?? false; + + if (shouldSearchBoxBeVisible || page is not ContentPage) + { + ViewModel.IsSearchBoxVisible = shouldSearchBoxBeVisible; + SearchBox.Focus(FocusState.Programmatic); + SearchBox.SelectSearch(); + } + else + { + _ = Task.Run( + async () => + { + if (token.IsCancellationRequested) + { + return; + } + + try + { + await page.DispatcherQueue.EnqueueAsync( + async () => + { + // I hate this so much, but it can take a while for the page to be ready to accept focus; + // focusing page with MarkdownTextBlock takes up to 5 attempts (* 100ms delay between attempts) + for (var i = 0; i < 10; i++) + { + token.ThrowIfCancellationRequested(); + + if (FocusManager.FindFirstFocusableElement(page) is FrameworkElement frameworkElement) + { + var set = frameworkElement.Focus(FocusState.Programmatic); + if (set) + { + break; + } + } + + await Task.Delay(100, token); + } + + token.ThrowIfCancellationRequested(); + + // Update the search box visibility based on the current page: + // - We do this here after navigation so the focus is not jumping around too much, + // it messes with screen readers if we do it too early + // - Since this should hide the search box on content pages, it's not a problem if we + // wait for the code above to finish trying to focus the content + ViewModel.IsSearchBoxVisible = ViewModel.CurrentPage?.HasSearchBox ?? false; + }); + } + catch (OperationCanceledException) + { + // Swallow cancellation - another FocusAfterLoaded invocation superseded this one + } + catch (Exception ex) + { + Logger.LogError("Error during FocusAfterLoaded async focus work", ex); + } + }, + token); + } + } + + private void AnnounceNavigationToPage(Page page) + { + var pageTitle = page switch + { + ListPage listPage => listPage.ViewModel?.Title, + ContentPage contentPage => contentPage.ViewModel?.Title, + _ => null, + }; + + if (string.IsNullOrEmpty(pageTitle)) + { + pageTitle = ResourceLoaderInstance.GetString("UntitledPageTitle"); + } + + var announcement = string.Format(CultureInfo.CurrentCulture, _pageNavigatedAnnouncement.Format, pageTitle); + + UIHelper.AnnounceActionForAccessibility(RootFrame, announcement, "CommandPalettePageNavigatedTo"); } /// @@ -452,11 +629,67 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, } } - private void ShellPage_OnPreviewKeyDown(object sender, KeyRoutedEventArgs e) + private static void ShellPage_OnPreviewKeyDown(object sender, KeyRoutedEventArgs e) { - if (e.Key == VirtualKey.Left && e.KeyStatus.IsMenuKeyDown) + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); + + var onlyAlt = altPressed && !ctrlPressed && !shiftPressed && !winPressed; + var onlyCtrl = !altPressed && ctrlPressed && !shiftPressed && !winPressed; + switch (e.Key) + { + case VirtualKey.Left when onlyAlt: // Alt+Left arrow + WeakReferenceMessenger.Default.Send(new()); + e.Handled = true; + break; + case VirtualKey.Home when onlyAlt: // Alt+Home + WeakReferenceMessenger.Default.Send(new(WithAnimation: false)); + e.Handled = true; + break; + case (VirtualKey)188 when onlyCtrl: // Ctrl+, + WeakReferenceMessenger.Default.Send(new()); + e.Handled = true; + break; + default: + { + // The CommandBar is responsible for handling all the item keybindings, + // since the bound context item may need to then show another + // context menu + TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + WeakReferenceMessenger.Default.Send(msg); + e.Handled = msg.Handled; + break; + } + } + } + + private static void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e) + { + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + if (ctrlPressed && e.Key == VirtualKey.Enter) + { + // ctrl+enter + WeakReferenceMessenger.Default.Send(); + e.Handled = true; + } + else if (e.Key == VirtualKey.Enter) + { + WeakReferenceMessenger.Default.Send(); + e.Handled = true; + } + else if (ctrlPressed && e.Key == VirtualKey.K) + { + // ctrl+k + WeakReferenceMessenger.Default.Send(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); + e.Handled = true; + } + else if (e.Key == VirtualKey.Escape) { WeakReferenceMessenger.Default.Send(new()); + e.Handled = true; } } @@ -479,4 +712,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, Logger.LogError("Error handling mouse button press event", ex); } } + + public void Dispose() + { + _focusAfterLoadedCts?.Cancel(); + _focusAfterLoadedCts?.Dispose(); + _focusAfterLoadedCts = null; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs index e7ba073a9b..d14dd391bd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs @@ -5,7 +5,7 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using ManagedCommon; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.MainPage; @@ -117,7 +117,7 @@ internal sealed class PowerToysRootPageService : IRootPageService } catch (Exception ex) { - Logger.LogError(ex.ToString()); + ManagedCommon.Logger.LogError(ex.ToString()); } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-arm64.pubxml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-arm64.pubxml index adf22122d9..882c64e3e7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-arm64.pubxml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-arm64.pubxml @@ -11,9 +11,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121. bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ true False - False - True - True - False \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-x64.pubxml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-x64.pubxml index 7f6d14d1ad..c686bf808b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-x64.pubxml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-x64.pubxml @@ -11,9 +11,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121. bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ true False - False - True - True - False \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json index febacfc92e..4631e9aeaf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json @@ -5,6 +5,11 @@ "nativeDebugging": false, "doNotLaunchApp": false }, + "Microsoft.CmdPal.UI (Package) + Native debugging": { + "commandName": "MsixPackage", + "nativeDebugging": true, + "doNotLaunchApp": false + }, "Microsoft.CmdPal.UI (Unpackaged)": { "commandName": "Project" } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs index e1ef0cb57f..d32256efed 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs @@ -2,7 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml index 5a495a2a21..72b51fd724 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml @@ -33,6 +33,10 @@ x:Key="StringEmptyToBoolConverter" EmptyValue="False" NotEmptyValue="True" /> + @@ -47,22 +51,34 @@ MaxWidth="1000" HorizontalAlignment="Stretch" Spacing="{StaticResource SettingsCardSpacing}"> - - + + + + + + + + + + + + + + - - - - - @@ -84,22 +100,22 @@ - - + - - - - - - + + + + + + + + - @@ -130,11 +146,8 @@ - - - @@ -145,8 +158,13 @@ Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Visibility="{x:Bind ViewModel.HasSettings}" /> - - + - - - diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml index 56ec56fe20..9e9600bd6e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml @@ -14,19 +14,204 @@ xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" mc:Ignorable="d"> + + + + + + + + + + ms-appx:///Assets/StoreLogo.dark.svg + + + + + + + + + + + + + + + + ms-appx:///Assets/StoreLogo.light.svg + + + + + ms-appx:///Assets/StoreLogo.dark.svg + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - @@ -57,5 +240,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs index e164638ebb..e99296bad9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs @@ -8,6 +8,7 @@ using Microsoft.CmdPal.UI.ViewModels; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; namespace Microsoft.CmdPal.UI.Settings; @@ -35,4 +36,10 @@ public sealed partial class ExtensionsPage : Page } } } + + private void OnFindInvoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + SearchBox?.Focus(FocusState.Keyboard); + args.Handled = true; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml index bfff5db1f7..97aa0e4768 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml @@ -37,22 +37,20 @@ - - - - + + + + + - - - @@ -65,6 +63,7 @@ + @@ -88,6 +87,10 @@ + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml index 7798b5588b..baab5c16c2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml @@ -6,6 +6,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:Microsoft.CmdPal.UI.Settings" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:text="using:Windows.UI.Text" xmlns:ui="using:CommunityToolkit.WinUI" xmlns:winuiex="using:WinUIEx" Title="SettingsWindow" @@ -24,10 +25,14 @@ - + @@ -36,18 +41,19 @@ + Loaded="NavView_Loaded"> 15,0,0,0 - - - - - - - - - - - - 28 - 7,4,8,0 - SemiBold - 16 - - - - + + + + + + + + + + 28 + 7,4,8,0 + SemiBold + 16 + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs index b3e1647294..5d042a09e3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs @@ -14,6 +14,7 @@ using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; using WinUIEx; using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; +using TitleBar = Microsoft.UI.Xaml.Controls.TitleBar; namespace Microsoft.CmdPal.UI.Settings; @@ -34,7 +35,7 @@ public sealed partial class SettingsWindow : WindowEx, var title = RS_.GetString("SettingsWindowTitle"); this.AppWindow.Title = title; this.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall; - this.TitleBar.Title = title; + this.AppTitleBar.Title = title; PositionCentered(); WeakReferenceMessenger.Default.Register(this); @@ -142,11 +143,13 @@ public sealed partial class SettingsWindow : WindowEx, { if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal) { - NavView.IsPaneToggleButtonVisible = false; + AppTitleBar.IsPaneToggleButtonVisible = true; + WorkAroundIcon.Margin = new Thickness(8, 0, 16, 0); // Required for workaround, see XAML comment } else { - NavView.IsPaneToggleButtonVisible = true; + AppTitleBar.IsPaneToggleButtonVisible = false; + WorkAroundIcon.Margin = new Thickness(16, 0, 0, 0); // Required for workaround, see XAML comment } } @@ -155,6 +158,11 @@ public sealed partial class SettingsWindow : WindowEx, // This might come in on a background thread DispatcherQueue.TryEnqueue(() => Close()); } + + private void AppTitleBar_PaneToggleRequested(TitleBar sender, object args) + { + NavView.IsPaneOpen = !NavView.IsPaneOpen; + } } public readonly struct Crumb diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index 0a61f66653..9c66be3773 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -257,8 +257,8 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Disabled Displayed when an extension is disabled - - Enable this extension + + Enable Displayed on a toggle controlling the extension's enabled / disabled state @@ -312,7 +312,13 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Alias - Typing this alias will navigate to this command. Direct aliases navigate as soon as you type the alias. Indirect aliases navigate after typing a trailing space. + A short keyword used to navigate to this command. + + + Alias activation + + + Choose when the alias runs. Direct runs as soon as you type the alias. Indirect runs after a trailing space. Built-in @@ -407,6 +413,12 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Choose if Command Palette is visible in the system tray + + Disable animations + + + Disable animations when switching between pages + Back @@ -416,6 +428,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut. More + + Last position + Reopen the window where it was last closed + Settings @@ -423,12 +439,15 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Close Close as a verb, as in Close the application - + Direct - + Indirect + + Enter alias + Show status messages @@ -448,6 +467,60 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Trigger reload of the extension externally with the x-cmdpal://reload command - For Developers + For developers + + + an untitled + + + Navigated to {0} page + + + Settings (Ctrl+,) + + + No extensions found + + + Try a different search term + + + More options + + + Reload extensions + + + Reloading extensions.. + + + Discover more extensions + + + Find more extensions on the Microsoft Store or WinGet. + + + Learn how to create your own extensions + + + Find extensions on the Microsoft Store + + + Microsoft Store + + + Find extensions on WinGet + + + Microsoft Store + + + Search extensions + + + Command Palette has encountered a fatal error and must close. + + + Command Palette - Fatal error \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Button.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Button.xaml deleted file mode 100644 index 0cca33265e..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Button.xaml +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Settings.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Settings.xaml index 0413bbe5cb..89c01814eb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Settings.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Settings.xaml @@ -2,7 +2,7 @@ - + 4 diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Themes/Generic.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Themes/Generic.xaml deleted file mode 100644 index 6903d112e3..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Themes/Generic.xaml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs index 87e04dfdcf..1a49fec8e0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs @@ -12,6 +12,7 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; using Windows.Win32; using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; using Windows.Win32.Graphics.Gdi; using Windows.Win32.UI.HiDpi; using Windows.Win32.UI.WindowsAndMessaging; @@ -24,21 +25,21 @@ public sealed partial class ToastWindow : WindowEx, IRecipient { private readonly HWND _hwnd; + private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + private readonly HiddenOwnerWindowBehavior _hiddenOwnerWindowBehavior = new(); public ToastViewModel ViewModel { get; } = new(); - private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); - public ToastWindow() { this.InitializeComponent(); AppWindow.Hide(); - this.SetVisibilityInSwitchers(false); ExtendsContentIntoTitleBar = true; AppWindow.SetPresenter(AppWindowPresenterKind.CompactOverlay); this.SetIcon(); AppWindow.Title = RS_.GetString("ToastWindowTitle"); AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; + _hiddenOwnerWindowBehavior.ShowInTaskbar(this, false); _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); PInvoke.EnableWindow(_hwnd, false); diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj index c2fdabb6b2..6e474cf5f3 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj @@ -3,9 +3,15 @@ ..\..\..\..\ - $(PathToRoot)packages\Microsoft.WindowsAppSDK.1.7.250513003 + $(PathToRoot)packages\Microsoft.WindowsAppSDK.1.8.250907003 + + + + + + true true @@ -200,6 +206,12 @@ + + + + + + @@ -210,6 +222,18 @@ + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/packages.config b/src/modules/cmdpal/Microsoft.Terminal.UI/packages.config index a516cb061d..2fb34c8fed 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/packages.config +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/packages.config @@ -4,4 +4,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs deleted file mode 100644 index 2ee3deeb5d..0000000000 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs +++ /dev/null @@ -1,42 +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; - -namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; - -[TestClass] -public class BookmarkDataTests -{ - [TestMethod] - public void BookmarkDataWebUrlDetection() - { - // Act - var webBookmark = new BookmarkData - { - Name = "Test Site", - Bookmark = "https://test.com", - }; - - var nonWebBookmark = new BookmarkData - { - Name = "Local File", - Bookmark = "C:\\temp\\file.txt", - }; - - var placeholderBookmark = new BookmarkData - { - Name = "Placeholder", - Bookmark = "{Placeholder}", - }; - - // Assert - Assert.IsTrue(webBookmark.IsWebUrl()); - Assert.IsFalse(webBookmark.IsPlaceholder); - Assert.IsFalse(nonWebBookmark.IsWebUrl()); - Assert.IsFalse(nonWebBookmark.IsPlaceholder); - - Assert.IsTrue(placeholderBookmark.IsPlaceholder); - } -} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs index e442818f8a..a813ac4464 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; @@ -191,7 +193,7 @@ public class BookmarkJsonParserTests public void SerializeBookmarks_ValidBookmarks_ReturnsJsonString() { // Arrange - var bookmarks = new Bookmarks + var bookmarks = new BookmarksData { Data = new List { @@ -216,7 +218,7 @@ public class BookmarkJsonParserTests public void SerializeBookmarks_EmptyBookmarks_ReturnsValidJson() { // Arrange - var bookmarks = new Bookmarks(); + var bookmarks = new BookmarksData(); // Act var result = _parser.SerializeBookmarks(bookmarks); @@ -241,7 +243,7 @@ public class BookmarkJsonParserTests public void ParseBookmarks_RoundTripSerialization_PreservesData() { // Arrange - var originalBookmarks = new Bookmarks + var originalBookmarks = new BookmarksData { Data = new List { @@ -263,7 +265,6 @@ public class BookmarkJsonParserTests { Assert.AreEqual(originalBookmarks.Data[i].Name, parsedBookmarks.Data[i].Name); Assert.AreEqual(originalBookmarks.Data[i].Bookmark, parsedBookmarks.Data[i].Bookmark); - Assert.AreEqual(originalBookmarks.Data[i].IsPlaceholder, parsedBookmarks.Data[i].IsPlaceholder); } } @@ -296,70 +297,6 @@ public class BookmarkJsonParserTests // Assert Assert.IsNotNull(result); Assert.AreEqual(3, result.Data.Count); - - Assert.IsFalse(result.Data[0].IsPlaceholder); - Assert.IsTrue(result.Data[1].IsPlaceholder); - Assert.IsTrue(result.Data[2].IsPlaceholder); - } - - [TestMethod] - public void ParseBookmarks_IsWebUrl_CorrectlyIdentifiesWebUrls() - { - // Arrange - var json = """ - { - "Data": [ - { - "Name": "HTTPS Website", - "Bookmark": "https://www.google.com" - }, - { - "Name": "HTTP Website", - "Bookmark": "http://example.com" - }, - { - "Name": "Website without protocol", - "Bookmark": "www.github.com" - }, - { - "Name": "Local File Path", - "Bookmark": "C:\\Users\\test\\Documents\\file.txt" - }, - { - "Name": "Network Path", - "Bookmark": "\\\\server\\share\\file.txt" - }, - { - "Name": "Executable", - "Bookmark": "notepad.exe" - }, - { - "Name": "File URI", - "Bookmark": "file:///C:/temp/file.txt" - } - ] - } - """; - - // Act - var result = _parser.ParseBookmarks(json); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(7, result.Data.Count); - - // Web URLs should return true - Assert.IsTrue(result.Data[0].IsWebUrl(), "HTTPS URL should be identified as web URL"); - Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTP URL should be identified as web URL"); - - // This case will fail. We need to consider if we need to support pure domain value in bookmark. - // Assert.IsTrue(result.Data[2].IsWebUrl(), "Domain without protocol should be identified as web URL"); - - // Non-web URLs should return false - Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file path should not be identified as web URL"); - Assert.IsFalse(result.Data[4].IsWebUrl(), "Network path should not be identified as web URL"); - Assert.IsFalse(result.Data[5].IsWebUrl(), "Executable should not be identified as web URL"); - Assert.IsFalse(result.Data[6].IsWebUrl(), "File URI should not be identified as web URL"); } [TestMethod] @@ -415,23 +352,10 @@ public class BookmarkJsonParserTests // Assert Assert.IsNotNull(result); Assert.AreEqual(9, result.Data.Count); - - // Should be identified as placeholders - Assert.IsTrue(result.Data[0].IsPlaceholder, "Simple placeholder should be identified"); - Assert.IsTrue(result.Data[1].IsPlaceholder, "Multiple placeholders should be identified"); - Assert.IsTrue(result.Data[2].IsPlaceholder, "Web URL with placeholder should be identified"); - Assert.IsTrue(result.Data[3].IsPlaceholder, "Complex placeholder should be identified"); - Assert.IsTrue(result.Data[8].IsPlaceholder, "Empty placeholder should be identified"); - - // Should NOT be identified as placeholders - Assert.IsFalse(result.Data[4].IsPlaceholder, "Regular URL should not be placeholder"); - Assert.IsFalse(result.Data[5].IsPlaceholder, "Local file should not be placeholder"); - Assert.IsFalse(result.Data[6].IsPlaceholder, "Only opening brace should not be placeholder"); - Assert.IsFalse(result.Data[7].IsPlaceholder, "Only closing brace should not be placeholder"); } [TestMethod] - public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesBothWebUrlAndPlaceholder() + public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesPlaceholder() { // Arrange var json = """ @@ -463,73 +387,5 @@ public class BookmarkJsonParserTests // Assert Assert.IsNotNull(result); Assert.AreEqual(4, result.Data.Count); - - // Web URL with placeholder - Assert.IsTrue(result.Data[0].IsWebUrl(), "Web URL with placeholder should be identified as web URL"); - Assert.IsTrue(result.Data[0].IsPlaceholder, "Web URL with placeholder should be identified as placeholder"); - - // Web URL without placeholder - Assert.IsTrue(result.Data[1].IsWebUrl(), "Web URL without placeholder should be identified as web URL"); - Assert.IsFalse(result.Data[1].IsPlaceholder, "Web URL without placeholder should not be identified as placeholder"); - - // Local file with placeholder - Assert.IsFalse(result.Data[2].IsWebUrl(), "Local file with placeholder should not be identified as web URL"); - Assert.IsTrue(result.Data[2].IsPlaceholder, "Local file with placeholder should be identified as placeholder"); - - // Local file without placeholder - Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file without placeholder should not be identified as web URL"); - Assert.IsFalse(result.Data[3].IsPlaceholder, "Local file without placeholder should not be identified as placeholder"); - } - - [TestMethod] - public void ParseBookmarks_EdgeCaseUrls_CorrectlyIdentifiesWebUrls() - { - // Arrange - var json = """ - { - "Data": [ - { - "Name": "FTP URL", - "Bookmark": "ftp://files.example.com" - }, - { - "Name": "HTTPS with port", - "Bookmark": "https://localhost:8080" - }, - { - "Name": "IP Address", - "Bookmark": "http://192.168.1.1" - }, - { - "Name": "Subdomain", - "Bookmark": "https://api.github.com" - }, - { - "Name": "Domain only", - "Bookmark": "example.com" - }, - { - "Name": "Not a URL - no dots", - "Bookmark": "localhost" - } - ] - } - """; - - // Act - var result = _parser.ParseBookmarks(json); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(6, result.Data.Count); - - Assert.IsFalse(result.Data[0].IsWebUrl(), "FTP URL should not be identified as web URL"); - Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTPS with port should be identified as web URL"); - Assert.IsTrue(result.Data[2].IsWebUrl(), "IP Address with HTTP should be identified as web URL"); - Assert.IsTrue(result.Data[3].IsWebUrl(), "Subdomain should be identified as web URL"); - - // This case will fail. We need to consider if we need to support pure domain value in bookmark. - // Assert.IsTrue(result.Data[4].IsWebUrl(), "Domain only should be identified as web URL"); - Assert.IsFalse(result.Data[5].IsWebUrl(), "Single word without dots should not be identified as web URL"); } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs new file mode 100644 index 0000000000..b4e533d66d --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs @@ -0,0 +1,233 @@ +// 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.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class BookmarkManagerTests +{ + [TestMethod] + public void BookmarkManager_CanBeInstantiated() + { + // Arrange & Act + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + + // Assert + Assert.IsNotNull(bookmarkManager); + } + + [TestMethod] + public void BookmarkManager_InitialBookmarksEmpty() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + + // Act + var bookmarks = bookmarkManager.Bookmarks; + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(0, bookmarks.Count); + } + + [TestMethod] + public void BookmarkManager_InitialBookmarksCorruptedData() + { + // Arrange + var json = "@*>$ß Corrupted data. Hey, this is not JSON!"; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks; + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(0, bookmarks.Count); + } + + [TestMethod] + public void BookmarkManager_InitializeWithExistingData() + { + // Arrange + const string json = """ + { + "Data":[ + {"Id":"d290f1ee-6c54-4b01-90e6-d701748f0851","Name":"Bookmark1","Bookmark":"C:\\Path1"}, + {"Id":"c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a","Name":"Bookmark2","Bookmark":"D:\\Path2"} + ] + } + """; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks?.ToList(); + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(2, bookmarks.Count); + + Assert.AreEqual("Bookmark1", bookmarks[0].Name); + Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark); + Assert.AreEqual(Guid.Parse("d290f1ee-6c54-4b01-90e6-d701748f0851"), bookmarks[0].Id); + + Assert.AreEqual("Bookmark2", bookmarks[1].Name); + Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark); + Assert.AreEqual(Guid.Parse("c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a"), bookmarks[1].Id); + } + + [TestMethod] + public void BookmarkManager_InitializeWithLegacyData_GeneratesIds() + { + // Arrange + const string json = """ + { + "Data": + [ + { "Name":"Bookmark1", "Bookmark":"C:\\Path1" }, + { "Name":"Bookmark2", "Bookmark":"D:\\Path2" } + ] + } + """; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks?.ToList(); + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(2, bookmarks.Count); + + Assert.AreEqual("Bookmark1", bookmarks[0].Name); + Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark); + Assert.AreNotEqual(Guid.Empty, bookmarks[0].Id); + + Assert.AreEqual("Bookmark2", bookmarks[1].Name); + Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark); + Assert.AreNotEqual(Guid.Empty, bookmarks[1].Id); + + Assert.AreNotEqual(bookmarks[0].Id, bookmarks[1].Id); + } + + [TestMethod] + public void BookmarkManager_AddBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var bookmarkAddedEventFired = false; + bookmarkManager.BookmarkAdded += (bookmark) => + { + bookmarkAddedEventFired = true; + Assert.AreEqual("TestBookmark", bookmark.Name); + Assert.AreEqual("C:\\TestPath", bookmark.Bookmark); + }; + + // Act + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.AreEqual(1, bookmarks.Count); + Assert.AreEqual(addedBookmark, bookmarks.First()); + Assert.IsTrue(bookmarkAddedEventFired); + } + + [TestMethod] + public void BookmarkManager_RemoveBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + var bookmarkRemovedEventFired = false; + bookmarkManager.BookmarkRemoved += (bookmark) => + { + bookmarkRemovedEventFired = true; + Assert.AreEqual(addedBookmark, bookmark); + }; + + // Act + var removeResult = bookmarkManager.Remove(addedBookmark.Id); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.IsTrue(removeResult); + Assert.AreEqual(0, bookmarks.Count); + Assert.IsTrue(bookmarkRemovedEventFired); + } + + [TestMethod] + public void BookmarkManager_UpdateBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + var bookmarkUpdatedEventFired = false; + bookmarkManager.BookmarkUpdated += (data, bookmarkData) => + { + bookmarkUpdatedEventFired = true; + Assert.AreEqual(addedBookmark, data); + Assert.AreEqual("UpdatedBookmark", bookmarkData.Name); + Assert.AreEqual("D:\\UpdatedPath", bookmarkData.Bookmark); + }; + + // Act + var updatedBookmark = bookmarkManager.Update(addedBookmark.Id, "UpdatedBookmark", "D:\\UpdatedPath"); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.IsNotNull(updatedBookmark); + Assert.AreEqual(1, bookmarks.Count); + Assert.AreEqual(updatedBookmark, bookmarks.First()); + Assert.AreEqual("UpdatedBookmark", updatedBookmark.Name); + Assert.AreEqual("D:\\UpdatedPath", updatedBookmark.Bookmark); + Assert.IsTrue(bookmarkUpdatedEventFired); + } + + [TestMethod] + public void BookmarkManager_LegacyData_IdsArePersistedAcrossLoads() + { + // Arrange + const string json = """ + { + "Data": + [ + { "Name": "C:\\","Bookmark": "C:\\" }, + { "Name": "Bing.com","Bookmark": "https://bing.com" } + ] + } + """; + + var dataSource = new MockBookmarkDataSource(json); + + // First load: IDs should be generated for legacy entries + var manager1 = new BookmarksManager(dataSource); + var firstLoad = manager1.Bookmarks.ToList(); + Assert.AreEqual(2, firstLoad.Count); + Assert.AreNotEqual(Guid.Empty, firstLoad[0].Id); + Assert.AreNotEqual(Guid.Empty, firstLoad[1].Id); + + // Keep a name->id map to be insensitive to ordering + var firstIdsByName = firstLoad.ToDictionary(b => b.Name, b => b.Id); + + // Wait deterministically for async persistence to complete + var wasSaved = dataSource.WaitForSave(1, 5000); + Assert.IsTrue(wasSaved, "Data was not saved within the expected time."); + + // Second load: should read back the same IDs from persisted data + var manager2 = new BookmarksManager(dataSource); + var secondLoad = manager2.Bookmarks.ToList(); + Assert.AreEqual(2, secondLoad.Count); + + var secondIdsByName = secondLoad.ToDictionary(b => b.Name, b => b.Id); + + foreach (var kvp in firstIdsByName) + { + Assert.IsTrue(secondIdsByName.ContainsKey(kvp.Key), $"Missing bookmark '{kvp.Key}' after reload."); + Assert.AreEqual(kvp.Value, secondIdsByName[kvp.Key], $"Bookmark '{kvp.Key}' upgraded ID was not persisted across loads."); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs new file mode 100644 index 0000000000..9be4a187e8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(nameof(CommonClassificationData.CommonCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateCommonClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(nameof(CommonClassificationData.UwpAumidCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUwpAumidClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedRelativePaths), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUnquotedRelativePathScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedShellProtocol), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUnquotedShellProtocolScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + private static class CommonClassificationData + { + public static IEnumerable CommonCases() + { + return + [ + [ + new PlaceholderClassificationCase( + Name: "HTTPS URL", + Input: "https://microsoft.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://microsoft.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "WWW URL without scheme", + Input: "www.example.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://www.example.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "HTTP URL with query", + Input: "http://yahoo.com?p=search", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "http://yahoo.com?p=search", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Mailto protocol", + Input: "mailto:user@example.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "mailto:user@example.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "MS-Settings protocol", + Input: "ms-settings:display", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "ms-settings:display", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Custom protocol", + Input: "myapp:doit", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "myapp:doit", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Not really a valid protocol", + Input: "this is not really a protocol myapp: doit", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "this", + ExpectedArguments: "is not really a protocol myapp: doit", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Drive", + Input: "C:\\.", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: "C:\\", + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Non-existing path with extension", + Input: "C:\\this-folder-should-not-exist-12345\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\this-folder-should-not-exist-12345\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Unknown fallback", + Input: "some_unlikely_command_name_12345", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "some_unlikely_command_name_12345", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + + [new PlaceholderClassificationCase( + Name: "Simple unquoted executable path", + Input: "C:\\Windows\\System32\\notepad.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Windows\\System32\\notepad.exe", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Unquoted document path (non existed file)", + Input: "C:\\Users\\John\\Documents\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Users\\John\\Documents\\file.txt", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ] + ]; + } + + public static IEnumerable UwpAumidCases() => + [ + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with AppsFolder prefix", + Input: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with AppsFolder prefix and argument (Trap)", + Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID via AppsFolder", + Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + ]; + + public static IEnumerable UnquotedShellProtocol() => + [ + [ + new PlaceholderClassificationCase( + Name: "Shell for This PC (shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D})", + Input: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell for This PC (::{20D04FE0-3AEA-1069-A2D8-08002B30309D})", + Input: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for My Documents (shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + Input: "shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for My Documents (::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + Input: "::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for AppData (shell:appdata)", + Input: "shell:appdata", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + + // let's pray this works on all systems + new PlaceholderClassificationCase( + Name: "Shell protocol for AppData + subpath (shell:appdata\\microsoft)", + Input: "shell:appdata\\microsoft", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft"), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + ]; + + public static IEnumerable UnquotedRelativePaths() => + [ + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative current path", + Input: ".\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], +#if CMDPAL_ENABLE_UNSAFE_TESTS + It's not really a good idea blindly write to directory out of user profile + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative parent path", + Input: "..\\parent folder\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], +#endif // CMDPAL_ENABLE_UNSAFE_TESTS + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative home folder", + Input: $"~\\{_testDirName}\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(_testDirPath, "app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs new file mode 100644 index 0000000000..c4c455d5a9 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs @@ -0,0 +1,369 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(nameof(PlaceholderClassificationData.PlaceholderCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Placeholders_ValidatePlaceholderClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(nameof(PlaceholderClassificationData.EdgeCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Placeholders_ValidatePlaceholderEdgeCases(PlaceholderClassificationCase c) + { + // Arrange + IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser()); + + // Act & Assert - Should not throw exceptions + var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None); + + Assert.IsNotNull(classification); + Assert.AreEqual(c.ExpectSuccess, classification.Success); + + if (c.ExpectSuccess && classification.Result != null) + { + Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder); + Assert.AreEqual(c.Input, classification.Result.Input, "OriginalInput should be preserved"); + } + } + + private static class PlaceholderClassificationData + { + public static IEnumerable PlaceholderCases() + { + // UWP/AUMID with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with package placeholder", + Input: "shell:AppsFolder\\{packageFamily}!{appId}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\{packageFamily}!{appId}", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + + // Expects no special handling + new PlaceholderClassificationCase( + Name: "Bare UWP AUMID with placeholders", + Input: "{packageFamily}!{appId}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{packageFamily}!{appId}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Web URLs with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "HTTPS URL with domain placeholder", + Input: "https://{domain}/path", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://{domain}/path", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "WWW URL with site placeholder", + Input: "www.{site}.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://www.{site}.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "WWW URL - Yahoo with Search", + Input: "http://yahoo.com?p={search}", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "http://yahoo.com?p={search}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Protocol URLs with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Mailto protocol with email placeholder", + Input: "mailto:{email}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "mailto:{email}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "MS-Settings protocol with category placeholder", + Input: "ms-settings:{category}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "ms-settings:{category}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // File executables with placeholders - These might classify as Unknown currently + // due to nonexistent paths, but should preserve placeholder flag + yield return + [ + new PlaceholderClassificationCase( + Name: "Executable with profile path placeholder", + Input: "{userProfile}\\Documents\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist + ExpectedTarget: "{userProfile}\\Documents\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Executable with program files placeholder", + Input: "{programFiles}\\MyApp\\tool.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist + ExpectedTarget: "{programFiles}\\MyApp\\tool.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Commands with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Command with placeholder and arguments", + Input: "{editor} {filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, // Likely Unknown since command won't be found in PATH + ExpectedTarget: "{editor}", + ExpectedArguments: "{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Directory paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Directory with user profile placeholder", + Input: "{userProfile}\\Documents", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, // May be Unknown if path doesn't exist during classification + ExpectedTarget: "{userProfile}\\Documents", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Complex quoted paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Quoted executable path with placeholders and args", + Input: "\"{programFiles}\\{appName}\\{executable}.exe\" --verbose", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // Likely Unknown due to nonexistent path + ExpectedTarget: "{programFiles}\\{appName}\\{executable}.exe", + ExpectedArguments: "--verbose", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Shell paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Shell folder with placeholder", + Input: "shell:{folder}\\{filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:{folder}\\{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Shell paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Shell folder with placeholder", + Input: "shell:knownFolder\\{filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:knownFolder\\{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + yield return + [ + + // cmd /K {param1} + new PlaceholderClassificationCase( + Name: "Command with braces in arguments", + Input: "cmd /K {param1}", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedArguments: "/K {param1}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Mixed literal and placeholder paths + yield return + [ + new PlaceholderClassificationCase( + Name: "Mixed literal and placeholder path", + Input: "C:\\{folder}\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // Behavior depends on partial path resolution + ExpectedTarget: "C:\\{folder}\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Multiple placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Multiple placeholders in path", + Input: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + } + + public static IEnumerable EdgeCases() + { + // Empty and malformed placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Empty placeholder", + Input: "{} file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{} file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Unclosed placeholder", + Input: "{unclosed file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{unclosed file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with spaces", + Input: "{with spaces}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{with spaces}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Nested placeholders", + Input: "{outer{inner}}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{outer{inner}}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Only closing brace", + Input: "file} something", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "file} something", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + // Very long placeholder names + yield return + [ + new PlaceholderClassificationCase( + Name: "Very long placeholder name", + Input: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Special characters in placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with underscores", + Input: "{user_profile}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{user_profile}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with numbers", + Input: "{path123}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{path123}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs new file mode 100644 index 0000000000..ceda208996 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs @@ -0,0 +1,669 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.MixedQuotesScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateMixedQuotesScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EscapedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEscapedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.PartialMalformedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidatePartialMalformedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EnvironmentVariablesWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEnvironmentVariablesWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.ShellProtocolPathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateShellProtocolPathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.CommandFlagsAndOptions), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateCommandFlagsAndOptions(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.NetworkPathsUnc), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateNetworkPathsUnc(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RelativePathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateRelativePathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EmptyAndWhitespaceCases), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEmptyAndWhitespaceCases(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RealWorldCommandScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateRealWorldCommandScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.SpecialCharactersInPaths), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateSpecialCharactersInPaths(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsCurrentlyBroken), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedPathsCurrentlyBroken(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsInCommands), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedPathsInCommands(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedAumid), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedUwpAppAumidCommands(PlaceholderClassificationCase c) => await RunShared(c: c); + + public static class QuotedClassificationData + { + public static IEnumerable MixedQuotesScenarios() => + [ + [ + new PlaceholderClassificationCase( + Name: "Executable with quoted argument", + Input: "C:\\Windows\\notepad.exe \"C:\\my file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Windows\\notepad.exe", + ExpectedArguments: "\"C:\\my file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "App with quoted argument containing spaces", + Input: "app.exe \"argument with spaces\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "app.exe", + ExpectedArguments: "\"argument with spaces\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Tool with input flag and quoted file", + Input: "C:\\tool.exe -input \"data file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\tool.exe", + ExpectedArguments: "-input \"data file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Multiple quoted arguments after path", + Input: "\"C:\\Program Files\\app.exe\" -file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedArguments: "-file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Command with two quoted paths", + Input: "cmd /c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedArguments: "/c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable EscapedQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Path with escaped quotes in folder name", + Input: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing escaped quote", + Input: "\"C:\\Windows\\\\\\\"\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: "C:\\Windows\\", + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable PartialMalformedQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Unclosed quote at start", + Input: "\"C:\\Program Files\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quote in middle of unquoted path", + Input: "C:\\Some\\\"Path\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Some\\\"Path\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Unclosed quote - never ends", + Input: "\"Starts quoted but never ends", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "Starts quoted but never ends", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable EnvironmentVariablesWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted environment variable path with spaces", + Input: "\"%ProgramFiles%\\MyApp\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "MyApp", "app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted USERPROFILE with document path", + Input: "\"%USERPROFILE%\\Documents\\file with spaces.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents", "file with spaces.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Environment variable with trailing args", + Input: "\"%ProgramFiles%\\App\" with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"), + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Environment variable with trailing args", + Input: "%ProgramFiles%\\App with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"), + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + ]; + + public static IEnumerable ShellProtocolPathsWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted shell:Downloads", + Input: "\"shell:Downloads\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted shell:Downloads with subpath", + Input: "\"shell:Downloads\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "file.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Shell Desktop with subpath", + Input: "shell:Desktop\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "file.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted shell path with trailing text", + Input: "\"shell:Programs\" extra", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(paths: Environment.GetFolderPath(Environment.SpecialFolder.Programs)), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable CommandFlagsAndOptions() => + [ + [ + new PlaceholderClassificationCase( + Name: "Path followed by flag with quoted value", + Input: "C:\\app.exe -flag \"value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\app.exe", + ExpectedArguments: "-flag \"value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted tool with equals-style flag", + Input: "\"C:\\Program Files\\tool.exe\" --input=file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\tool.exe", + ExpectedArguments: "--input=file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Path with slash option and quoted value", + Input: "C:\\tool.exe /option \"quoted value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\tool.exe", + ExpectedArguments: "/option \"quoted value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Flag before quoted path", + Input: "--path \"C:\\Program Files\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "--path", + ExpectedArguments: "\"C:\\Program Files\\app.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable NetworkPathsUnc() => + [ + [ + new PlaceholderClassificationCase( + Name: "UNC path unquoted", + Input: "\\\\server\\share\\folder\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "\\\\server\\share\\folder\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UNC path with spaces", + Input: "\"\\\\server\\share with spaces\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "\\\\server\\share with spaces\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "UNC path with trailing args", + Input: "\"\\\\server\\share\\\" with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "\\\\server\\share\\", + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UNC app with flag", + Input: "\"\\\\server\\My Share\\app.exe\" --flag", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "\\\\server\\My Share\\app.exe", + ExpectedArguments: "--flag", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable RelativePathsWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted relative current path", + Input: "\".\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted relative parent path", + Input: "\"..\\parent folder\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted relative home folder", + Input: "\"~\\current folder\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "current folder\\app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable EmptyAndWhitespaceCases() => + [ + [ + new PlaceholderClassificationCase( + Name: "Empty string", + Input: string.Empty, + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Only whitespace", + Input: " ", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: " ", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Just empty quotes", + Input: "\"\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted single space", + Input: "\" \"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: " ", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable RealWorldCommandScenarios() => + [ +#if CMDPAL_ENABLE_UNSAFE_TESTS + [ + new PlaceholderClassificationCase( + Name: "Git clone command with full exe path with quoted path", + Input: "\"C:\\Program Files\\Git\\bin\\git.exe\" clone repo", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\Git\\bin\\git.exe", + ExpectedArguments: "clone repo", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Git clone command with quoted path", + Input: "git clone repo", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Program Files\\Git\\cmd\\git.EXE", + ExpectedArguments: "clone repo", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Visual Studio devenv with solution", + Input: "\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe\" solution.sln", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe", + ExpectedArguments: "solution.sln", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Double-quoted Windows cmd pattern", + Input: "cmd /c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedArguments: "/c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"", + ExpectedIsPlaceholder: false) + ], +#endif + [ + new PlaceholderClassificationCase( + Name: "PowerShell script with execution policy", + Input: "powershell -ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\PowerShell.exe", + ExpectedArguments: "-ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + ]; + + public static IEnumerable SpecialCharactersInPaths() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted path with square brackets", + Input: "\"C:\\Path\\file[1].txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Path\\file[1].txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with parentheses", + Input: "\"C:\\Folder (2)\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Folder (2)\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with hyphens and underscores", + Input: "\"C:\\Path\\file_name-123.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Path\\file_name-123.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable QuotedPathsCurrentlyBroken() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted path with spaces - complete path", + Input: "\"C:\\Program Files\\MyApp\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\MyApp\\app.exe", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with spaces in user folder", + Input: "\"C:\\Users\\John Doe\\Documents\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Users\\John Doe\\Documents\\file.txt", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing arguments", + Input: "\"C:\\Program Files\\app.exe\" --flag", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedArguments: "--flag", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with multiple arguments", + Input: "\"C:\\My Documents\\file.txt\" -output result.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\My Documents\\file.txt", + ExpectedArguments: "-output result.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing flag and value", + Input: "\"C:\\Tools\\converter.exe\" input.txt output.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Tools\\converter.exe", + ExpectedArguments: "input.txt output.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable QuotedPathsInCommands() => + [ + [ + new PlaceholderClassificationCase( + Name: "cmd /c with quoted path", + Input: "cmd /c \"C:\\Program Files\\tool.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.exe", + ExpectedArguments: "/c \"C:\\Program Files\\tool.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "PowerShell with quoted script path", + Input: "powershell -File \"C:\\Scripts\\my script.ps1\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", path4: "powershell.exe"), + ExpectedArguments: "-File \"C:\\Scripts\\my script.ps1\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "runas with quoted executable", + Input: "runas /user:admin \"C:\\Windows\\System32\\cmd.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\runas.exe", + ExpectedArguments: "/user:admin \"C:\\Windows\\System32\\cmd.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable QuotedAumid() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted UWP AUMID via AppsFolder", + Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UWP AUMID with AppsFolder prefix and argument", + Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\" --maximized", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedArguments: "--maximized", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + ]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs new file mode 100644 index 0000000000..16378a7cd7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs @@ -0,0 +1,102 @@ +// 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. + +#nullable enable + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private static string _testDirPath; + private static string _userHomeDirPath; + private static string _testDirName; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + [ClassInitialize] + public static void ClassSetup(TestContext context) + { + _userHomeDirPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + _testDirName = "CmdPalBookmarkTests" + Guid.NewGuid().ToString("N"); + _testDirPath = Path.Combine(_userHomeDirPath, _testDirName); + Directory.CreateDirectory(_testDirPath); + + // test files in user home + File.WriteAllText(Path.Combine(_userHomeDirPath, "file.txt"), "This is a test text file."); + + // test files in test dir + File.WriteAllText(Path.Combine(_testDirPath, "file.txt"), "This is a test text file."); + File.WriteAllText(Path.Combine(_testDirPath, "app.exe"), "This is a test text file."); + } + + [ClassCleanup] + public static void ClassCleanup() + { + if (Directory.Exists(_testDirPath)) + { + Directory.Delete(_testDirPath, true); + } + + if (File.Exists(Path.Combine(_userHomeDirPath, "file.txt"))) + { + File.Delete(Path.Combine(_userHomeDirPath, "file.txt")); + } + } + + // must be public static to be used as DataTestMethod data source + public static string FromCase(MethodInfo method, object[] data) + => data is [PlaceholderClassificationCase c] + ? c.Name + : $"{method.Name}({string.Join(", ", data.Select(row => row.ToString()))})"; + + private static async Task RunShared(PlaceholderClassificationCase c) + { + // Arrange + IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser()); + + // Act + var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None); + + // Assert + Assert.IsNotNull(classification); + Assert.AreEqual(c.ExpectSuccess, classification.Success, "Success flag mismatch."); + + if (c.ExpectSuccess) + { + Assert.IsNotNull(classification.Result, "Result should not be null for successful classification."); + Assert.AreEqual(c.ExpectedKind, classification.Result.Kind, $"CommandKind mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedTarget, classification.Result.Target, StringComparer.OrdinalIgnoreCase, $"Target mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedLaunch, classification.Result.Launch, $"LaunchMethod mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedArguments, classification.Result.Arguments, $"Arguments mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder, $"IsPlaceholder mismatch for input: {c.Input}"); + + if (c.ExpectedDisplayName != null) + { + Assert.AreEqual(c.ExpectedDisplayName, classification.Result.DisplayName, $"DisplayName mismatch for input: {c.Input}"); + } + } + } + + public sealed record PlaceholderClassificationCase( + string Name, // Friendly name for Test Explorer + string Input, // Input string passed to classifier + bool ExpectSuccess, // Expected Success flag + CommandKind ExpectedKind, // Expected Result.Kind + string ExpectedTarget, // Expected Result.Target (normalized) + LaunchMethod ExpectedLaunch, // Expected Result.Launch + bool ExpectedIsPlaceholder, // Expected Result.IsPlaceholder + string ExpectedArguments = "", // Expected Result.Arguments + string? ExpectedDisplayName = null // Expected Result.DisplayName + ); +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs index 52f50727a7..82b961649c 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs @@ -2,9 +2,9 @@ // 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 System.Linq; -using Microsoft.CmdPal.Ext.Bookmarks; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; @@ -16,8 +16,8 @@ public class BookmarksCommandProviderTests public void ProviderHasCorrectId() { // Setup - var mockDataSource = new MockBookmarkDataSource(); - var provider = new BookmarksCommandProvider(mockDataSource); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Assert Assert.AreEqual("Bookmarks", provider.Id); @@ -27,8 +27,8 @@ public class BookmarksCommandProviderTests public void ProviderHasDisplayName() { // Setup - var mockDataSource = new MockBookmarkDataSource(); - var provider = new BookmarksCommandProvider(mockDataSource); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Assert Assert.IsNotNull(provider.DisplayName); @@ -39,7 +39,8 @@ public class BookmarksCommandProviderTests public void ProviderHasIcon() { // Setup - var provider = new BookmarksCommandProvider(); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Assert Assert.IsNotNull(provider.Icon); @@ -49,7 +50,8 @@ public class BookmarksCommandProviderTests public void TopLevelCommandsNotEmpty() { // Setup - var provider = new BookmarksCommandProvider(); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Act var commands = provider.TopLevelCommands(); @@ -60,47 +62,40 @@ public class BookmarksCommandProviderTests } [TestMethod] - public void ProviderWithMockData_LoadsBookmarksCorrectly() + [Timeout(5000)] + public async Task ProviderWithMockData_LoadsBookmarksCorrectly() { // Arrange - var jsonData = @"{ - ""Data"": [ - { - ""Name"": ""Test Bookmark"", - ""Bookmark"": ""https://test.com"" - }, - { - ""Name"": ""Another Bookmark"", - ""Bookmark"": ""https://another.com"" - } - ] - }"; - - var dataSource = new MockBookmarkDataSource(jsonData); - var provider = new BookmarksCommandProvider(dataSource); + var mockBookmarkManager = new MockBookmarkManager( + new BookmarkData("Test Bookmark", "http://test.com"), + new BookmarkData("Another Bookmark", "http://another.com")); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Act var commands = provider.TopLevelCommands(); // Assert - Assert.IsNotNull(commands); - - var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); - var testBookmark = commands.Where(c => c.Title.Contains("Test Bookmark")).FirstOrDefault(); + Assert.IsNotNull(commands, "commands != null"); // Should have three commands:Add + two custom bookmarks Assert.AreEqual(3, commands.Length); - Assert.IsNotNull(addCommand); - Assert.IsNotNull(testBookmark); + // Wait until all BookmarkListItem commands are initialized + await Task.WhenAll(commands.OfType().Select(t => t.IsInitialized)); + + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); + var testBookmark = commands.FirstOrDefault(c => c.Title.Contains("Test Bookmark")); + + Assert.IsNotNull(addCommand, "addCommand != null"); + Assert.IsNotNull(testBookmark, "testBookmark != null"); } [TestMethod] public void ProviderWithEmptyData_HasOnlyAddCommand() { // Arrange - var dataSource = new MockBookmarkDataSource(@"{ ""Data"": [] }"); - var provider = new BookmarksCommandProvider(dataSource); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Act var commands = provider.TopLevelCommands(); @@ -111,7 +106,7 @@ public class BookmarksCommandProviderTests // Only have Add command Assert.AreEqual(1, commands.Length); - var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); Assert.IsNotNull(addCommand); } @@ -120,7 +115,7 @@ public class BookmarksCommandProviderTests { // Arrange var dataSource = new MockBookmarkDataSource("invalid json"); - var provider = new BookmarksCommandProvider(dataSource); + var provider = new BookmarksCommandProvider(new MockBookmarkManager()); // Act var commands = provider.TopLevelCommands(); @@ -131,7 +126,7 @@ public class BookmarksCommandProviderTests // Only have one command. Will ignore json parse error. Assert.AreEqual(1, commands.Length); - var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); Assert.IsNotNull(addCommand); } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs new file mode 100644 index 0000000000..977f3b5006 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable +using System; +using System.IO; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class CommandLineHelperTests +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private static string _tempTestDir; + + private static string _tempTestFile; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + [ClassInitialize] + public static void ClassSetup(TestContext context) + { + // Create temporary test directory and file + _tempTestDir = Path.Combine(Path.GetTempPath(), "CommandLineHelperTests_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempTestDir); + + _tempTestFile = Path.Combine(_tempTestDir, "testfile.txt"); + File.WriteAllText(_tempTestFile, "test"); + } + + [ClassCleanup] + public static void ClassCleanup() + { + // Clean up test directory + if (Directory.Exists(_tempTestDir)) + { + Directory.Delete(_tempTestDir, true); + } + } + + [TestMethod] + [DataRow("%TEMP%", false, true, DisplayName = "Expands TEMP environment variable")] + [DataRow("%USERPROFILE%", false, true, DisplayName = "Expands USERPROFILE environment variable")] + [DataRow("%SystemRoot%", false, true, DisplayName = "Expands SystemRoot environment variable")] + public void Expand_WithEnvironmentVariables_ExpandsCorrectly(string input, bool expandShell, bool shouldExist) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.AreEqual(shouldExist, result, $"Expected result {shouldExist} for input '{input}'"); + if (shouldExist) + { + Assert.IsFalse(full.Contains('%'), "Output should not contain % symbols after expansion"); + Assert.IsTrue(Path.Exists(full), $"Expanded path '{full}' should exist"); + } + } + + [TestMethod] + [DataRow("shell:Downloads", true, DisplayName = "Expands shell:Downloads when expandShell is true")] + [DataRow("shell:Desktop", true, DisplayName = "Expands shell:Desktop when expandShell is true")] + [DataRow("shell:Documents", true, DisplayName = "Expands shell:Documents when expandShell is true")] + public void Expand_WithShellPaths_ExpandsWhenFlagIsTrue(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + if (result) + { + Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved"); + Assert.IsTrue(Path.Exists(full), $"Expanded shell path '{full}' should exist"); + } + + // Note: Result may be false if ShellNames.TryGetFileSystemPath fails + } + + [TestMethod] + [DataRow("shell:Personal", false, DisplayName = "Does not expand shell: when expandShell is false")] + public void Expand_WithShellPaths_DoesNotExpandWhenFlagIsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert - shell: paths won't exist as literal paths + Assert.IsFalse(result, "Should return false for unexpanded shell path"); + Assert.AreEqual(input, full, "Output should match input when not expanding shell paths"); + } + + [TestMethod] + [DataRow("shell:Personal\\subfolder", true, "\\subfolder", DisplayName = "Expands shell path with subfolder")] + [DataRow("shell:Desktop\\test.txt", true, "\\test.txt", DisplayName = "Expands shell path with file")] + public void Expand_WithShellPathsAndSubpaths_CombinesCorrectly(string input, bool expandShell, string expectedEnding) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Note: Result depends on whether the combined path exists + if (result) + { + Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved"); + Assert.IsTrue(full.EndsWith(expectedEnding, StringComparison.OrdinalIgnoreCase), "Output should end with the subpath"); + } + } + + [TestMethod] + public void Expand_WithExistingDirectory_ReturnsFullPath() + { + // Arrange + var input = _tempTestDir; + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full); + + // Assert + Assert.IsTrue(result, "Should return true for existing directory"); + Assert.AreEqual(Path.GetFullPath(_tempTestDir), full, "Should return full path"); + } + + [TestMethod] + public void Expand_WithExistingFile_ReturnsFullPath() + { + // Arrange + var input = _tempTestFile; + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full); + + // Assert + Assert.IsTrue(result, "Should return true for existing file"); + Assert.AreEqual(Path.GetFullPath(_tempTestFile), full, "Should return full path"); + } + + [TestMethod] + [DataRow("C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", false, "C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", DisplayName = "Nonexistent absolute path")] + [DataRow("NonExistentFile.txt", false, "NonExistentFile.txt", DisplayName = "Nonexistent relative path")] + public void Expand_WithNonExistentPath_ReturnsFalse(string input, bool expandShell, string expectedFull) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.IsFalse(result, "Should return false for nonexistent path"); + Assert.AreEqual(expectedFull, full, "Output should be empty string"); + } + + [TestMethod] + [DataRow("", false, DisplayName = "Empty string")] + [DataRow(" ", false, DisplayName = "Whitespace only")] + public void Expand_WithEmptyOrWhitespace_ReturnsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.IsFalse(result, "Should return false for empty/whitespace input"); + } + + [TestMethod] + [DataRow("%TEMP%\\testsubdir", false, DisplayName = "Env var with subdirectory")] + [DataRow("%USERPROFILE%\\Desktop", false, DisplayName = "USERPROFILE with Desktop")] + public void Expand_WithEnvironmentVariableAndSubpath_ExpandsCorrectly(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Result depends on whether the path exists + if (result) + { + Assert.IsFalse(full.Contains('%'), "Should expand environment variables"); + Assert.IsTrue(Path.Exists(full), "Expanded path should exist"); + } + } + + [TestMethod] + public void Expand_WithRelativePath_ConvertsToAbsoluteWhenExists() + { + // Arrange + var relativePath = Path.GetRelativePath(Environment.CurrentDirectory, _tempTestDir); + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(relativePath, false, out var full); + + // Assert + if (result) + { + Assert.IsTrue(Path.IsPathRooted(full), "Output should be absolute path"); + Assert.IsTrue(Path.Exists(full), "Expanded path should exist"); + } + } + + [TestMethod] + [DataRow("InvalidShell:Path", true, DisplayName = "Invalid shell path format")] + public void Expand_WithInvalidShellPath_ReturnsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + // If ShellNames.TryGetFileSystemPath returns false, method returns false + Assert.IsFalse(result || Path.Exists(full), "Should return false or path should not exist"); + } + + [DataTestMethod] + + // basic + [DataRow("cmd ping", "cmd", "ping")] + [DataRow("cmd ping pong", "cmd", "ping pong")] + [DataRow("cmd \"ping pong\"", "cmd", "\"ping pong\"")] + + // no tail / trailing whitespace after head + [DataRow("cmd", "cmd", "")] + [DataRow("cmd ", "cmd", "")] + + // spacing & tabs between args should be preserved in tail + [DataRow("cmd ping pong", "cmd", "ping pong")] + [DataRow("cmd\tping\tpong", "cmd", "ping\tpong")] + + // leading whitespace before head + [DataRow(" cmd ping", "", "cmd ping")] + [DataRow("\t cmd ping", "", "cmd ping")] + + // quoted tail variants + [DataRow("cmd \"\"", "cmd", "\"\"")] + [DataRow("cmd \"a \\\"quoted\\\" arg\" b", "cmd", "\"a \\\"quoted\\\" arg\" b")] + + // quoted head (spaces in path) + [DataRow(@"""C:\Program Files\nodejs\node.exe"" -v", @"C:\Program Files\nodejs\node.exe", "-v")] + [DataRow(@"""C:\Program Files\Git\bin\bash.exe""", @"C:\Program Files\Git\bin\bash.exe", "")] + [DataRow(@" ""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""", @"", @"""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""")] + [DataRow(@"""C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe"" Test.sln", @"C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe", "Test.sln")] + + // quoted simple head (still should strip quotes for head) + [DataRow(@"""cmd"" ping", "cmd", "ping")] + + // common CLI shapes + [DataRow("git --version", "git", "--version")] + [DataRow("dotnet build -c Release", "dotnet", "build -c Release")] + + // UNC paths + [DataRow("\"\\\\server\\share\\\" with args", "\\\\server\\share\\", "with args")] + public void SplitHeadAndArgs(string input, string expectedHead, string expectedTail) + { + // Act + var result = CommandLineHelper.SplitHeadAndArgs(input); + + // Assert + // If ShellNames.TryGetFileSystemPath returns false, method returns false + Assert.AreEqual(expectedHead, result.Head); + Assert.AreEqual(expectedTail, result.Tail); + } + + [DataTestMethod] + [DataRow(@"C:\program files\myapp\app.exe -param ""1"" -param 2", @"C:\program files\myapp\app.exe -param", @"""1"" -param 2")] + [DataRow(@"git commit -m test", "git commit -m test", "")] + [DataRow(@"""C:\Program Files\App\app.exe"" -v", "", @"""C:\Program Files\App\app.exe"" -v")] + [DataRow(@"tool a\\\""b c ""d e"" f", @"tool a\\\""b c", @"""d e"" f")] // escaped quote before first real one + [DataRow("C:\\Some\\\"Path\\file.txt", "C:\\Some\\\"Path\\file.txt", "")] + [DataRow(@" ""C:\p\app.exe"" -v", "", @"""C:\p\app.exe"" -v")] // first token is quoted + public void SplitLongestHeadBeforeQuotedArg_Tests(string input, string expectedHead, string expectedTail) + { + var (head, tail) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedTail, tail); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs index ae3732559c..02d71d6d77 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs @@ -1,11 +1,17 @@ // Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; internal sealed class MockBookmarkDataSource : IBookmarkDataSource { private string _jsonData; + private int _saveCount; public MockBookmarkDataSource(string initialJsonData = "[]") { @@ -20,5 +26,26 @@ internal sealed class MockBookmarkDataSource : IBookmarkDataSource public void SaveBookmarkData(string jsonData) { _jsonData = jsonData; + Interlocked.Increment(ref _saveCount); + } + + public int SaveCount => Volatile.Read(ref _saveCount); + + // Waits until at least expectedMinSaves have occurred or the timeout elapses. + // Returns true if the condition was met, false on timeout. + public bool WaitForSave(int expectedMinSaves = 1, int timeoutMs = 2000) + { + var start = Environment.TickCount; + while (Volatile.Read(ref _saveCount) < expectedMinSaves) + { + if (Environment.TickCount - start > timeoutMs) + { + return false; + } + + Thread.Sleep(50); + } + + return true; } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs new file mode 100644 index 0000000000..b3e48db791 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +#pragma warning disable CS0067 + +internal sealed class MockBookmarkManager : IBookmarksManager +{ + private readonly List _bookmarks; + + public event Action BookmarkAdded; + + public event Action BookmarkUpdated; + + public event Action BookmarkRemoved; + + public IReadOnlyCollection Bookmarks => _bookmarks; + + public BookmarkData Add(string name, string bookmark) => throw new NotImplementedException(); + + public bool Remove(Guid id) => throw new NotImplementedException(); + + public BookmarkData Update(Guid id, string name, string bookmark) => throw new NotImplementedException(); + + public MockBookmarkManager(params IEnumerable bookmarks) + { + _bookmarks = [.. bookmarks]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs new file mode 100644 index 0000000000..b7e5933aa8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class PlaceholderInfoNameEqualityComparerTests +{ + [TestMethod] + public void Equals_BothNull_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + + var result = comparer.Equals(null, null); + + Assert.IsTrue(result); + } + + [TestMethod] + public void Equals_OneNull_ReturnsFalse() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p = new PlaceholderInfo("name", 0); + + Assert.IsFalse(comparer.Equals(p, null)); + Assert.IsFalse(comparer.Equals(null, p)); + } + + [TestMethod] + public void Equals_SameNameDifferentIndex_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("name", 0); + var p2 = new PlaceholderInfo("name", 10); + + Assert.IsTrue(comparer.Equals(p1, p2)); + } + + [TestMethod] + public void Equals_DifferentNameSameIndex_ReturnsFalse() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("first", 3); + var p2 = new PlaceholderInfo("second", 3); + + Assert.IsFalse(comparer.Equals(p1, p2)); + } + + [TestMethod] + public void Equals_CaseInsensitive_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("Name", 0); + var p2 = new PlaceholderInfo("name", 5); + + Assert.IsTrue(comparer.Equals(p1, p2)); + Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2)); + } + + [TestMethod] + public void GetHashCode_SameNameDifferentIndex_SameHash() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("same", 1); + var p2 = new PlaceholderInfo("same", 99); + + Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2)); + } + + [TestMethod] + public void GetHashCode_Null_ThrowsArgumentNullException() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + Assert.ThrowsException(() => comparer.GetHashCode(null!)); + } + + [TestMethod] + public void Instance_ReturnsSingleton() + { + var a = PlaceholderInfoNameEqualityComparer.Instance; + var b = PlaceholderInfoNameEqualityComparer.Instance; + + Assert.IsNotNull(a); + Assert.AreSame(a, b); + } + + [TestMethod] + public void HashSet_UsesNameEquality_IgnoresIndex() + { + var set = new HashSet(PlaceholderInfoNameEqualityComparer.Instance) + { + new("dup", 0), + new("DUP", 10), + new("unique", 0), + }; + + Assert.AreEqual(2, set.Count); + Assert.IsTrue(set.Contains(new PlaceholderInfo("dup", 123))); + Assert.IsTrue(set.Contains(new PlaceholderInfo("UNIQUE", 999))); + Assert.IsFalse(set.Contains(new PlaceholderInfo("missing", 0))); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs new file mode 100644 index 0000000000..31abeb0195 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class PlaceholderParserTests +{ + private IPlaceholderParser _parser; + + [TestInitialize] + public void Setup() + { + _parser = new PlaceholderParser(); + } + + public static IEnumerable ValidPlaceholderTestData => + [ + [ + "Hello {name}!", + true, + "Hello ", + new[] { "name" }, + new[] { 6 } + ], + [ + "User {user_name} has {count} items", + true, + "User ", + new[] { "user_name", "count" }, + new[] { 5, 21 } + ], + [ + "Order {order-id} for {name} by {name}", + true, + "Order ", + new[] { "order-id", "name", "name" }, + new[] { 6, 21, 31 } + ], + [ + "{start} and {end}", + true, + string.Empty, + new[] { "start", "end" }, + new[] { 0, 12 } + ], + [ + "Number {123} and text {abc}", + true, + "Number ", + new[] { "123", "abc" }, + new[] { 7, 22 } + ] + ]; + + public static IEnumerable InvalidPlaceholderTestData => + [ + [string.Empty, false, string.Empty, Array.Empty()], + ["No placeholders here", false, "No placeholders here", Array.Empty()], + ["GUID: {550e8400-e29b-41d4-a716-446655440000}", false, "GUID: {550e8400-e29b-41d4-a716-446655440000}", Array.Empty()], + ["Invalid {user.name} placeholder", false, "Invalid {user.name} placeholder", Array.Empty()], + ["Empty {} placeholder", false, "Empty {} placeholder", Array.Empty()], + ["Unclosed {placeholder", false, "Unclosed {placeholder", Array.Empty()], + ["No opening brace placeholder}", false, "No opening brace placeholder}", Array.Empty()], + ["Invalid chars {user@domain}", false, "Invalid chars {user@domain}", Array.Empty()], + ["Spaces { name }", false, "Spaces { name }", Array.Empty()] + ]; + + [TestMethod] + [DynamicData(nameof(ValidPlaceholderTestData))] + public void ParsePlaceholders_ValidInput_ReturnsExpectedResults( + string input, + bool expectedResult, + string expectedHead, + string[] expectedPlaceholderNames, + int[] expectedIndexes) + { + // Act + var result = _parser.ParsePlaceholders(input, out var head, out var placeholders); + + // Assert + Assert.AreEqual(expectedResult, result); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count); + + var actualNames = placeholders.Select(p => p.Name).ToArray(); + var actualIndexes = placeholders.Select(p => p.Index).ToArray(); + + // Validate names and indexes (allow duplicates, ignore order) + CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames); + CollectionAssert.AreEquivalent(expectedIndexes, actualIndexes); + + // Validate name-index pairing exists for each expected placeholder occurrence + for (var i = 0; i < expectedPlaceholderNames.Length; i++) + { + var expectedName = expectedPlaceholderNames[i]; + var expectedIndex = expectedIndexes[i]; + Assert.IsTrue( + placeholders.Any(p => p.Name == expectedName && p.Index == expectedIndex), + $"Expected placeholder '{{{expectedName}}}' at index {expectedIndex} was not found."); + } + } + + [TestMethod] + [DynamicData(nameof(InvalidPlaceholderTestData))] + public void ParsePlaceholders_InvalidInput_ReturnsExpectedResults( + string input, + bool expectedResult, + string expectedHead, + string[] expectedPlaceholderNames) + { + // Act + var result = _parser.ParsePlaceholders(input, out var head, out var placeholders); + + // Assert + Assert.AreEqual(expectedResult, result); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count); + + var actualNames = placeholders.Select(p => p.Name).ToArray(); + CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames); + } + + [TestMethod] + public void ParsePlaceholders_NullInput_ThrowsArgumentNullException() + { + Assert.ThrowsException(() => _parser.ParsePlaceholders(null!, out _, out _)); + } + + [TestMethod] + public void Placeholder_Equality_WorksCorrectly() + { + // Arrange + var placeholder1 = new PlaceholderInfo("name", 0); + var placeholder2 = new PlaceholderInfo("name", 0); + var placeholder3 = new PlaceholderInfo("other", 0); + var placeholder4 = new PlaceholderInfo("name", 1); + + // Assert + Assert.AreEqual(placeholder1, placeholder2); + Assert.AreNotEqual(placeholder1, placeholder3); + Assert.AreEqual(placeholder1.GetHashCode(), placeholder2.GetHashCode()); + Assert.AreNotEqual(placeholder1, placeholder4); + Assert.AreNotEqual(placeholder1.GetHashCode(), placeholder4.GetHashCode()); + } + + [TestMethod] + public void Placeholder_ToString_ReturnsName() + { + // Arrange + var placeholder = new PlaceholderInfo("userName", 0); + + // Assert + Assert.AreEqual("userName", placeholder.ToString()); + } + + [TestMethod] + public void Placeholder_Constructor_ThrowsOnNull() + { + // Assert + Assert.ThrowsException(() => new PlaceholderInfo(null!, 0)); + } + + [TestMethod] + public void Placeholder_Constructor_ThrowsArgumentOutOfRange() + { + // Assert + Assert.ThrowsException(() => new PlaceholderInfo("Name", -1)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs index 767460fa27..e079be0655 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs @@ -40,16 +40,4 @@ public class QueryTests : CommandPaletteUnitTestBase Assert.IsNotNull(githubBookmark); Assert.AreEqual("https://github.com", githubBookmark.Bookmark); } - - [TestMethod] - public void ValidateWebUrlDetection() - { - // Setup - var bookmarks = Settings.CreateDefaultBookmarks(); - var microsoftBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "Microsoft"); - - // Assert - Assert.IsNotNull(microsoftBookmark); - Assert.IsTrue(microsoftBookmark.IsWebUrl()); - } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs index 82d7cd1cad..3bfd7391d0 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs @@ -2,13 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; public static class Settings { - public static Bookmarks CreateDefaultBookmarks() + public static BookmarksData CreateDefaultBookmarks() { - var bookmarks = new Bookmarks(); + var bookmarks = new BookmarksData(); // Add some test bookmarks bookmarks.Data.Add(new BookmarkData diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs new file mode 100644 index 0000000000..4731cfeddc --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class UriHelperTests +{ + private static bool TryGetScheme(ReadOnlySpan input, out string scheme, out string remainder) + { + return UriHelper.TryGetScheme(input, out scheme, out remainder); + } + + [DataTestMethod] + [DataRow("http://example.com", "http", "//example.com")] + [DataRow("ftp:", "ftp", "")] + [DataRow("my-app:payload", "my-app", "payload")] + [DataRow("x-cmdpal://settings/", "x-cmdpal", "//settings/")] + [DataRow("custom+ext.-scheme:xyz", "custom+ext.-scheme", "xyz")] + [DataRow("MAILTO:foo@bar", "MAILTO", "foo@bar")] + [DataRow("a:b", "a", "b")] + public void TryGetScheme_ValidSchemes_ReturnsTrueAndSplits(string input, string expectedScheme, string expectedRemainder) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok, "Expected valid scheme."); + Assert.AreEqual(expectedScheme, scheme); + Assert.AreEqual(expectedRemainder, remainder); + } + + [TestMethod] + public void TryGetScheme_OnlySchemeAndColon_ReturnsEmptyRemainder() + { + var ok = TryGetScheme("http:".AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("http", scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [DataTestMethod] + [DataRow("123:http")] // starts with digit + [DataRow(":nope")] // colon at start + [DataRow("noColon")] // no colon at all + [DataRow("bad_scheme:")] // underscore not allowed + [DataRow("bad*scheme:")] // asterisk not allowed + [DataRow(":")] // syntactically invalid literal just for completeness; won't compile, example only + public void TryGetScheme_InvalidInputs_ReturnsFalse(string input) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsFalse(ok); + Assert.AreEqual(string.Empty, scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [TestMethod] + public void TryGetScheme_MultipleColons_SplitsOnFirst() + { + const string input = "shell:::{645FF040-5081-101B-9F08-00AA002F954E}"; + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("shell", scheme); + Assert.AreEqual("::{645FF040-5081-101B-9F08-00AA002F954E}", remainder); + } + + [TestMethod] + public void TryGetScheme_MinimumLength_OneLetterAndColon() + { + var ok = TryGetScheme("a:".AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("a", scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [TestMethod] + public void TryGetScheme_TooShort_ReturnsFalse() + { + Assert.IsFalse(TryGetScheme("a".AsSpan(), out _, out _), "No colon."); + Assert.IsFalse(TryGetScheme(":".AsSpan(), out _, out _), "Colon at start; no scheme."); + } + + [DataTestMethod] + [DataRow("HTTP://x", "HTTP", "//x")] + [DataRow("hTtP:rest", "hTtP", "rest")] + public void TryGetScheme_CaseIsPreserved(string input, string expectedScheme, string expectedRemainder) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual(expectedScheme, scheme); + Assert.AreEqual(expectedRemainder, remainder); + } + + [TestMethod] + public void TryGetScheme_WhitespaceInsideScheme_Fails() + { + Assert.IsFalse(TryGetScheme("ht tp:rest".AsSpan(), out _, out _)); + } + + [TestMethod] + public void TryGetScheme_PlusMinusDot_AllowedInMiddleOnly() + { + Assert.IsTrue(TryGetScheme("a+b.c-d:rest".AsSpan(), out var s1, out var r1)); + Assert.AreEqual("a+b.c-d", s1); + Assert.AreEqual("rest", r1); + + // The first character must be a letter; plus is not allowed as first char + Assert.IsFalse(TryGetScheme("+abc:rest".AsSpan(), out _, out _)); + Assert.IsFalse(TryGetScheme(".abc:rest".AsSpan(), out _, out _)); + Assert.IsFalse(TryGetScheme("-abc:rest".AsSpan(), out _, out _)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj new file mode 100644 index 0000000000..73abdbe772 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + + + false + Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests + true + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs new file mode 100644 index 0000000000..8635a5e3c5 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests; + +[TestClass] +public class UrlHelperTests +{ + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("\t")] + [DataRow("\r\n")] + public void IsValidUrl_ReturnsFalse_WhenUrlIsNullOrWhitespace(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("test\nurl")] + [DataRow("test\rurl")] + [DataRow("http://example.com\nmalicious")] + [DataRow("https://test.com\r\nheader")] + public void IsValidUrl_ReturnsFalse_WhenUrlContainsNewlines(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("com")] + [DataRow("org")] + [DataRow("localhost")] + [DataRow("test")] + [DataRow("http")] + [DataRow("https")] + public void IsValidUrl_ReturnsFalse_WhenUrlDoesNotContainDot(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("https://www.example.com")] + [DataRow("http://test.org")] + [DataRow("ftp://files.example.net")] + [DataRow("file://localhost/path/to/file.txt")] + [DataRow("https://subdomain.example.co.uk")] + [DataRow("http://192.168.1.1")] + [DataRow("https://example.com:8080/path")] + public void IsValidUrl_ReturnsTrue_WhenUrlIsWellFormedAbsolute(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("www.example.com")] + [DataRow("example.org")] + [DataRow("subdomain.test.net")] + [DataRow("github.com/user/repo")] + [DataRow("stackoverflow.com/questions/123")] + [DataRow("192.168.1.1")] + public void IsValidUrl_ReturnsTrue_WhenUrlIsValidWithoutProtocol(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("not a url")] + [DataRow("invalid..url")] + [DataRow("http://")] + [DataRow("https://")] + [DataRow("://example.com")] + [DataRow("ht tp://example.com")] + public void IsValidUrl_ReturnsFalse_WhenUrlIsInvalid(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(" https://www.example.com ")] + [DataRow("\t\tgithub.com\t\t")] + [DataRow(" \r\n stackoverflow.com \r\n ")] + public void IsValidUrl_TrimsWhitespace_BeforeValidation(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("tel:+1234567890")] + [DataRow("javascript:alert('test')")] + public void IsValidUrl_ReturnsFalse_ForNonWebProtocols(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void NormalizeUrl_ReturnsInput_WhenUrlIsNullOrWhitespace(string url) + { + // Act + var result = UrlHelper.NormalizeUrl(url); + + // Assert + Assert.AreEqual(url, result); + } + + [TestMethod] + [DataRow("https://www.example.com")] + [DataRow("http://test.org")] + [DataRow("ftp://files.example.net")] + [DataRow("file://localhost/path/to/file.txt")] + public void NormalizeUrl_ReturnsUnchanged_WhenUrlIsAlreadyWellFormed(string url) + { + // Act + var result = UrlHelper.NormalizeUrl(url); + + // Assert + Assert.AreEqual(url, result); + } + + [TestMethod] + [DataRow("www.example.com", "https://www.example.com")] + [DataRow("example.org", "https://example.org")] + [DataRow("github.com/user/repo", "https://github.com/user/repo")] + [DataRow("stackoverflow.com/questions/123", "https://stackoverflow.com/questions/123")] + public void NormalizeUrl_AddsHttpsPrefix_WhenNoProtocolPresent(string input, string expected) + { + // Act + var result = UrlHelper.NormalizeUrl(input); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(" www.example.com ", "https://www.example.com")] + [DataRow("\t\tgithub.com\t\t", "https://github.com")] + [DataRow(" \r\n stackoverflow.com \r\n ", "https://stackoverflow.com")] + public void NormalizeUrl_TrimsWhitespace_BeforeNormalizing(string input, string expected) + { + // Act + var result = UrlHelper.NormalizeUrl(input); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(@"C:\Users\Test\Documents\file.txt")] + [DataRow(@"D:\Projects\MyProject\readme.md")] + [DataRow(@"E:\")] + [DataRow(@"F:")] + [DataRow(@"G:\folder\subfolder")] + public void IsValidUrl_ReturnsTrue_ForValidLocalPaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(@"\\server\share")] + [DataRow(@"\\server\share\folder")] + [DataRow(@"\\192.168.1.100\public")] + [DataRow(@"\\myserver\documents\file.docx")] + [DataRow(@"\\domain.com\share\folder\file.pdf")] + public void IsValidUrl_ReturnsTrue_ForValidNetworkPaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(@"\\")] + [DataRow(@":")] + [DataRow(@"Z")] + [DataRow(@"folder")] + [DataRow(@"folder\file.txt")] + [DataRow(@"documents\project\readme.md")] + [DataRow(@"./config/settings.json")] + [DataRow(@"../data/input.csv")] + public void IsValidUrl_ReturnsFalse_ForInvalidPathsAndRelativePaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(@"C:\Users\Test\Documents\file.txt")] + [DataRow(@"D:\Projects\MyProject")] + [DataRow(@"E:\")] + public void NormalizeUrl_ConvertsLocalPathToFileUri_WhenValidLocalPath(string path) + { + // Act + var result = UrlHelper.NormalizeUrl(path); + + // Assert + Assert.IsTrue(result.StartsWith("file:///", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result.Contains(path.Replace('\\', '/'))); + } + + [TestMethod] + [DataRow(@"\\server\share")] + [DataRow(@"\\192.168.1.100\public")] + [DataRow(@"\\myserver\documents")] + public void NormalizeUrl_ConvertsNetworkPathToFileUri_WhenValidNetworkPath(string path) + { + // Act + var result = UrlHelper.NormalizeUrl(path); + + // Assert + Assert.IsTrue(result.StartsWith("file://", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result.Contains(path.Replace('\\', '/'))); + } + + [TestMethod] + [DataRow("file:///C:/Users/Test/file.txt")] + [DataRow("file://server/share/folder")] + public void NormalizeUrl_ReturnsUnchanged_WhenAlreadyFileUri(string fileUri) + { + // Act + var result = UrlHelper.NormalizeUrl(fileUri); + + // Assert + Assert.AreEqual(fileUri, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs new file mode 100644 index 0000000000..919790f198 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Shell.Helpers; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +[TestClass] +public class NormalizeCommandLineTests : CommandPaletteUnitTestBase +{ + private void NormalizeTestCore(string input, string expectedExe, string expectedArgs = "") + { + ShellListPageHelpers.NormalizeCommandLineAndArgs(input, out var exe, out var args); + + Assert.AreEqual(expectedExe, exe, ignoreCase: true, culture: System.Globalization.CultureInfo.InvariantCulture); + Assert.AreEqual(expectedArgs, args); + } + + [TestMethod] + [DataRow("ping bing.com", "c:\\Windows\\system32\\ping.exe", "bing.com")] + [DataRow("curl bing.com", "c:\\Windows\\system32\\curl.exe", "bing.com")] + [DataRow("ipconfig /all", "c:\\Windows\\system32\\ipconfig.exe", "/all")] + [DataRow("ipconfig a b \"c d\"", "c:\\Windows\\system32\\ipconfig.exe", "a b \"c d\"")] + public void NormalizeCommandLineSimple(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("\"C:\\Program Files\\Windows Defender\\MsMpEng.exe\"", "C:\\Program Files\\Windows Defender\\MsMpEng.exe")] + [DataRow("C:\\Program Files\\Windows Defender\\MsMpEng.exe", "C:\\Program Files\\Windows Defender\\MsMpEng.exe")] + public void NormalizeCommandLineSpacesInExecutablePath(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("%SystemRoot%\\system32\\cmd.exe", "C:\\Windows\\System32\\cmd.exe")] + public void NormalizeWithEnvVar(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("cmd --run --test", "C:\\Windows\\System32\\cmd.exe", "--run --test")] + [DataRow("cmd --run --test ", "C:\\Windows\\System32\\cmd.exe", "--run --test")] + [DataRow("cmd \"--run --test\" --pass", "C:\\Windows\\System32\\cmd.exe", "\"--run --test\" --pass")] + public void NormalizeArgsWithSpaces(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("ThereIsNoWayYouHaveAnExecutableNamedThisOnThePipeline", "ThereIsNoWayYouHaveAnExecutableNamedThisOnThePipeline", "")] + [DataRow("C:\\ThisPathDoesNotExist\\NoExecutable.exe", "C:\\ThisPathDoesNotExist\\NoExecutable.exe", "")] + public void NormalizeNonExistentExecutable(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("C:\\Windows", "c:\\Windows", "")] + [DataRow("C:\\Windows foo /bar", "c:\\Windows", "foo /bar")] + public void NormalizeDirectoryAsExecutable(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs index bf9fcac406..8618c815ba 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs @@ -3,11 +3,13 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Ext.Shell.Pages; using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CommandPalette.Extensions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -74,27 +76,34 @@ public class QueryTests : CommandPaletteUnitTestBase [DataRow("ping bing.com", "ping.exe")] [DataRow("curl bing.com", "curl.exe")] [DataRow("ipconfig /all", "ipconfig.exe")] + [DataRow("\"C:\\Program Files\\Windows Defender\\MsMpEng.exe\"", "MsMpEng.exe")] + [DataRow("C:\\Program Files\\Windows Defender\\MsMpEng.exe", "MsMpEng.exe")] public async Task QueryWithoutHistoryCommand(string command, string exeName) { // Setup var settings = Settings.CreateDefaultSettings(); var mockHistory = CreateMockHistoryService(); - var pages = new ShellListPage(settings, mockHistory.Object); + var pages = new ShellListPage(settings, mockHistory.Object, telemetryService: null); - pages.UpdateSearchText(string.Empty, command); - - // wait for about 1s. - await Task.Delay(1000); + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText(string.Empty, command); + }); var commandList = pages.GetItems(); Assert.AreEqual(1, commandList.Length); - var executeCommand = commandList.FirstOrDefault(); - Assert.IsNotNull(executeCommand); - Assert.IsNotNull(executeCommand.Icon); - Assert.IsTrue(executeCommand.Title.Contains(exeName), $"expect ${exeName} but got ${executeCommand.Title}"); + var listItem = commandList.FirstOrDefault(); + Assert.IsNotNull(listItem); + + var runExeListItem = listItem as RunExeItem; + Assert.IsNotNull(runExeListItem); + Assert.AreEqual(exeName, runExeListItem.Exe); + Assert.IsTrue(listItem.Title.Contains(exeName), $"expect ${exeName} but got ${listItem.Title}"); + Assert.IsNotNull(listItem.Icon); } [TestMethod] @@ -107,12 +116,13 @@ public class QueryTests : CommandPaletteUnitTestBase var settings = Settings.CreateDefaultSettings(); var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); - var pages = new ShellListPage(settings, mockHistoryService.Object); + var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); - // Test: Search for a command that exists in history - pages.UpdateSearchText(string.Empty, command); - - await Task.Delay(1000); + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText(string.Empty, command); + }); var commandList = pages.GetItems(); @@ -132,15 +142,144 @@ public class QueryTests : CommandPaletteUnitTestBase var settings = Settings.CreateDefaultSettings(); var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); - var pages = new ShellListPage(settings, mockHistoryService.Object); + var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); - pages.UpdateSearchText("abcdefg", string.Empty); - - await Task.Delay(1000); + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText("abcdefg", string.Empty); + }); var commandList = pages.GetItems(); // Should find at least the ping command from history Assert.IsTrue(commandList.Length > 1); } + + [TestMethod] + public async Task TestCacheBackToSameDirectory() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryService(); + + var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + // Load up everything in c:\, for the sake of comparing: + var filesInC = Directory.EnumerateFileSystemEntries("C:\\"); + + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; }); + + var commandList = page.GetItems(); + + // Should find only items for what's in c:\ + Assert.IsTrue(commandList.Length == filesInC.Count()); + + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Win"; }); + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; }); + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; }); + + commandList = page.GetItems(); + + // Should still find everything + Assert.IsTrue(commandList.Length == filesInC.Count()); + + await TypeStringIntoPage(page, "c:\\Windows\\Pro"); + await BackspaceSearchText(page, "c:\\Windows\\Pro", 3); // 3 characters for c:\ + + commandList = page.GetItems(); + + // Should still find everything + Assert.IsTrue(commandList.Length == filesInC.Count()); + } + + private async Task TypeStringIntoPage(IDynamicListPage page, string searchText) + { + // type the string one character at a time + for (var i = 0; i < searchText.Length; i++) + { + var substr = searchText[..i]; + await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; }); + } + } + + private async Task BackspaceSearchText(IDynamicListPage page, string originalSearchText, int finalStringLength) + { + var originalLength = originalSearchText.Length; + for (var i = originalLength; i >= finalStringLength; i--) + { + var substr = originalSearchText[..i]; + await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; }); + } + } + + [TestMethod] + public async Task TestCacheSameDirectorySlashy() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryService(); + + var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + // Load up everything in c:\, for the sake of comparing: + var filesInC = Directory.EnumerateFileSystemEntries("C:\\"); + var filesInWindows = Directory.EnumerateFileSystemEntries("C:\\Windows"); + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; }); + + var commandList = page.GetItems(); + Assert.IsTrue(commandList.Length == filesInC.Count()); + + // First navigate to c:\Windows. This should match everything that matches "windows" inside of C:\ + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; }); + var cWindowsCommandsPre = page.GetItems(); + + // Then go into c:\windows\. This will only have the results in c:\windows\ + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows\\"; }); + var windowsCommands = page.GetItems(); + Assert.IsTrue(windowsCommands.Length != cWindowsCommandsPre.Length); + + // now go back to c:\windows. This should match the results from the last time we entered this string + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; }); + var cWindowsCommandsPost = page.GetItems(); + Assert.IsTrue(cWindowsCommandsPre.Length == cWindowsCommandsPost.Length); + } + + [TestMethod] + public async Task TestPathWithSpaces() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryService(); + + var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + // Load up everything in c:\, for the sake of comparing: + var filesInC = Directory.EnumerateFileSystemEntries("C:\\"); + var filesInProgramFiles = Directory.EnumerateFileSystemEntries("C:\\Program Files"); + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; }); + + var commandList = page.GetItems(); + Assert.IsTrue(commandList.Length == filesInProgramFiles.Count()); + } + + [TestMethod] + public async Task TestNoWrapSuggestionsWithSpaces() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryService(); + + var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; }); + + var commandList = page.GetItems(); + + foreach (var item in commandList) + { + Assert.IsTrue(!string.IsNullOrEmpty(item.TextToSuggest)); + Assert.IsFalse(item.TextToSuggest.StartsWith('"')); + } + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs index 42fb0900a4..24a3252255 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs @@ -2,7 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -16,7 +16,7 @@ public class ShellCommandProviderTests { // Setup var mockHistoryService = new Mock(); - var provider = new ShellCommandsProvider(mockHistoryService.Object); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); // Assert Assert.IsNotNull(provider.DisplayName); @@ -28,7 +28,7 @@ public class ShellCommandProviderTests { // Setup var mockHistoryService = new Mock(); - var provider = new ShellCommandsProvider(mockHistoryService.Object); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); // Assert Assert.IsNotNull(provider.Icon); @@ -39,7 +39,7 @@ public class ShellCommandProviderTests { // Setup var mockHistoryService = new Mock(); - var provider = new ShellCommandsProvider(mockHistoryService.Object); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); // Act var commands = provider.TopLevelCommands(); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs index 5b11ba6e05..af17ad8ec3 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs @@ -25,7 +25,7 @@ public class QueryTests : CommandPaletteUnitTestBase [DataRow("hibernate", "Hibernate")] [DataRow("open recycle", "Open Recycle Bin")] [DataRow("empty recycle", "Empty Recycle Bin")] - [DataRow("uefi", "UEFI Firmware Settings")] + [DataRow("uefi", "UEFI firmware settings")] public void TopLevelPageQueryTest(string input, string matchedTitle) { var settings = new Settings(); @@ -143,6 +143,6 @@ public class QueryTests : CommandPaletteUnitTestBase Assert.IsNotNull(result); var firstItem = result.FirstOrDefault(); var firstItemIsUefiCommand = firstItem?.Title.Contains("UEFI", StringComparison.OrdinalIgnoreCase) ?? false; - Assert.AreEqual(hasCommand, firstItemIsUefiCommand, $"Expected to match (or not match) 'UEFI Firmware Settings' but got '{firstItem?.Title}'"); + Assert.AreEqual(hasCommand, firstItemIsUefiCommand, $"Expected to match (or not match) 'UEFI firmware settings' but got '{firstItem?.Title}'"); } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs index 7553ca8321..00f1af30a8 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs @@ -41,7 +41,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests // Assert Assert.IsNotNull(provider); Assert.IsNotNull(provider.DisplayName); - Assert.AreEqual("DateTime", provider.Id); + Assert.AreEqual("com.microsoft.cmdpal.builtin.datetime", provider.Id); Assert.IsNotNull(provider.Icon); Assert.IsNotNull(provider.Settings); } @@ -103,7 +103,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests // Assert Assert.IsFalse(string.IsNullOrEmpty(subtitle)); - Assert.IsTrue(subtitle.Contains("Provides time and date values in different formats")); + Assert.IsTrue(subtitle.Contains("Show time and date values in different formats")); } } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs index a4da29e830..e00f198ab6 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs @@ -3,25 +3,44 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; using System.Linq; -using System.Text; using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; namespace Microsoft.CmdPal.Ext.UnitTestBase; public class CommandPaletteUnitTestBase { - private bool MatchesFilter(string filter, IListItem item) => StringMatcher.FuzzySearch(filter, item.Title).Success || StringMatcher.FuzzySearch(filter, item.Subtitle).Success; + private bool MatchesFilter(string filter, IListItem item) => + FuzzyStringMatcher.ScoreFuzzy(filter, item.Title) > 0 || + FuzzyStringMatcher.ScoreFuzzy(filter, item.Subtitle) > 0; public IListItem[] Query(string query, IListItem[] candidates) { - IListItem[] listItems = candidates + var listItems = candidates .Where(item => MatchesFilter(query, item)) .ToArray(); return listItems; } + + public async Task UpdatePageAndWaitForItems(IDynamicListPage page, Action modification) + { + // Add an event handler for the ItemsChanged event, + // Then call the modification action, + // and wait for the event to be raised. + var tcs = new TaskCompletionSource(); + + TypedEventHandler handleItemsChanged = (object s, IItemsChangedEventArgs e) => + { + tcs.TrySetResult(e); + }; + + page.ItemsChanged += handleItemsChanged; + modification(); + + await tcs.Task; + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs index c141d28d6e..ef8b56a1b8 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs @@ -16,7 +16,7 @@ public class WebSearchCommandProviderTests var provider = new WebSearchCommandsProvider(); // Assert - Assert.AreEqual("WebSearch", provider.Id); + Assert.AreEqual("com.microsoft.cmdpal.builtin.websearch", provider.Id); } [TestMethod] diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Settings.cs index e8271da371..cbbe365a1b 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Settings.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Settings.cs @@ -17,6 +17,7 @@ public class Settings : ISettingsInterface private readonly bool hideKillProcessOnElevatedProcesses; private readonly bool hideExplorerSettingInfo; private readonly bool inMruOrder; + private readonly bool useWindowIcon; public Settings( bool resultsFromVisibleDesktopOnly = false, @@ -27,7 +28,8 @@ public class Settings : ISettingsInterface bool openAfterKillAndClose = false, bool hideKillProcessOnElevatedProcesses = false, bool hideExplorerSettingInfo = true, - bool inMruOrder = true) + bool inMruOrder = true, + bool useWindowIcon = true) { this.resultsFromVisibleDesktopOnly = resultsFromVisibleDesktopOnly; this.subtitleShowPid = subtitleShowPid; @@ -38,6 +40,7 @@ public class Settings : ISettingsInterface this.hideKillProcessOnElevatedProcesses = hideKillProcessOnElevatedProcesses; this.hideExplorerSettingInfo = hideExplorerSettingInfo; this.inMruOrder = inMruOrder; + this.useWindowIcon = useWindowIcon; } public bool ResultsFromVisibleDesktopOnly => resultsFromVisibleDesktopOnly; @@ -57,4 +60,6 @@ public class Settings : ISettingsInterface public bool HideExplorerSettingInfo => hideExplorerSettingInfo; public bool InMruOrder => inMruOrder; + + public bool UseWindowIcon => useWindowIcon; } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj new file mode 100644 index 0000000000..9282141355 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + + + false + true + Microsoft.CmdPal.UI.ViewModels.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + enable + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs new file mode 100644 index 0000000000..78ead1588e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs @@ -0,0 +1,444 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CmdPal.UI.ViewModels.MainPage; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Foundation; +using WyHash; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public partial class RecentCommandsTests : CommandPaletteUnitTestBase +{ + private static RecentCommandsManager CreateHistory(IList? commandIds = null) + { + var history = new RecentCommandsManager(); + if (commandIds != null) + { + foreach (var item in commandIds) + { + history.AddHistoryItem(item); + } + } + + return history; + } + + private static RecentCommandsManager CreateBasicHistoryService() + { + var commonCommands = new List + { + "com.microsoft.cmdpal.shell", + "com.microsoft.cmdpal.windowwalker", + "Visual Studio 2022 Preview_6533433915015224980", + "com.microsoft.cmdpal.reload", + "com.microsoft.cmdpal.shell", + }; + + return CreateHistory(commonCommands); + } + + [TestMethod] + public void ValidateHistoryFunctionality() + { + // Setup + var history = CreateHistory(); + + // Act + history.AddHistoryItem("com.microsoft.cmdpal.shell"); + + // Assert + Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0); + } + + [TestMethod] + public void ValidateHistoryWeighting() + { + // Setup + var history = CreateBasicHistoryService(); + + // Act + var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell"); + var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker"); + var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980"); + var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload"); + var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command"); + + // Assert + Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell should be weighted higher than Window Walker, more uses"); + Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio should be weighted higher than Window Walker, because recency"); + Assert.AreEqual(reloadWeight, vsWeight, "both reload and VS were used in the last three commands, same weight"); + Assert.IsTrue(shellWeight > vsWeight, "VS and run were both used in the last 3, but shell has 2 more frequency"); + Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight"); + } + + private sealed partial record ListItemMock( + string Title, + string? Subtitle = "", + string? GivenId = "", + string? ProviderId = "") : IListItem + { + public string Id => string.IsNullOrEmpty(GivenId) ? GenerateId() : GivenId; + + public IDetails Details => throw new System.NotImplementedException(); + + public string Section => throw new System.NotImplementedException(); + + public ITag[] Tags => throw new System.NotImplementedException(); + + public string TextToSuggest => throw new System.NotImplementedException(); + + public ICommand Command => new NoOpCommand() { Id = Id }; + + public IIconInfo Icon => throw new System.NotImplementedException(); + + public IContextItem[] MoreCommands => throw new System.NotImplementedException(); + +#pragma warning disable CS0067 + public event TypedEventHandler? PropChanged; +#pragma warning restore CS0067 + + private string GenerateId() + { + // Use WyHash64 to generate stable ID hashes. + // manually seeding with 0, so that the hash is stable across launches + var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0); + return $"{ProviderId}{result}"; + } + } + + private static RecentCommandsManager CreateHistory(IList items) + { + var history = new RecentCommandsManager(); + foreach (var item in items) + { + history.AddHistoryItem(item.Id); + } + + return history; + } + + [TestMethod] + public void ValidateMocksWork() + { + // Setup + var items = new List + { + new("Command A", "Subtitle A", "idA", "providerA"), + new("Command B", "Subtitle B", GivenId: "idB"), + new("Command C", "Subtitle C", ProviderId: "providerC"), + new("Command A", "Subtitle A", "idA", "providerA"), // Duplicate to test incrementing uses + }; + + // Act + var history = CreateHistory(items); + + // Assert + foreach (var item in items) + { + var weight = history.GetCommandHistoryWeight(item.Id); + Assert.IsTrue(weight > 0, $"Item {item.Title} should have a weight greater than zero."); + } + + // Check that the duplicate item has a higher weight due to increased uses + var weightA = history.GetCommandHistoryWeight("idA"); + var weightB = history.GetCommandHistoryWeight("idB"); + var weightC = history.GetCommandHistoryWeight(items[2].Id); // providerC generated ID + Assert.IsTrue(weightA > weightB, "Item A should have a higher weight than Item B due to more uses."); + Assert.IsTrue(weightA > weightC, "Item A should have a higher weight than Item C due to more uses."); + Assert.AreEqual(weightC, weightB, "Item C and Item B were used in the last 3 commands"); + } + + [TestMethod] + public void ValidateHistoryBuckets() + { + // Setup + // (these will be checked in reverse order, so that A is the most recent) + var items = new List + { + new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0 + new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0 + new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0 + new("Command D", "Subtitle D", GivenId: "idD"), // #3 -> bucket 1 + new("Command E", "Subtitle E", GivenId: "idE"), // #4 -> bucket 1 + new("Command F", "Subtitle F", GivenId: "idF"), // #5 -> bucket 1 + new("Command G", "Subtitle G", GivenId: "idG"), // #6 -> bucket 1 + new("Command H", "Subtitle H", GivenId: "idH"), // #7 -> bucket 1 + new("Command I", "Subtitle I", GivenId: "idI"), // #8 -> bucket 1 + new("Command J", "Subtitle J", GivenId: "idJ"), // #9 -> bucket 1 + new("Command K", "Subtitle K", GivenId: "idK"), // #10 -> bucket 1 + new("Command L", "Subtitle L", GivenId: "idL"), // #11 -> bucket 2 + new("Command M", "Subtitle M", GivenId: "idM"), // #12 -> bucket 2 + new("Command N", "Subtitle N", GivenId: "idN"), // #13 -> bucket 2 + new("Command O", "Subtitle O", GivenId: "idO"), // #14 -> bucket 2 + }; + + for (var i = items.Count; i <= 50; i++) + { + items.Add(new ListItemMock($"Command #{i}", GivenId: $"id{i}")); + } + + // Act + var history = CreateHistory(items.Reverse().ToList()); + + // Assert + // First three items should be in the top bucket + var weightA = history.GetCommandHistoryWeight("idA"); + var weightB = history.GetCommandHistoryWeight("idB"); + var weightC = history.GetCommandHistoryWeight("idC"); + + Assert.AreEqual(weightA, weightB, "Items A and B were used in the last 3 commands"); + Assert.AreEqual(weightB, weightC, "Items B and C were used in the last 3 commands"); + + // Next eight items (3-10 inclusive) should be in the second bucket + var weightD = history.GetCommandHistoryWeight("idD"); + var weightE = history.GetCommandHistoryWeight("idE"); + var weightF = history.GetCommandHistoryWeight("idF"); + var weightG = history.GetCommandHistoryWeight("idG"); + var weightH = history.GetCommandHistoryWeight("idH"); + var weightI = history.GetCommandHistoryWeight("idI"); + var weightJ = history.GetCommandHistoryWeight("idJ"); + var weightK = history.GetCommandHistoryWeight("idK"); + + Assert.AreEqual(weightD, weightE, "Items D and E were used in the last 10 commands"); + Assert.AreEqual(weightE, weightF, "Items E and F were used in the last 10 commands"); + Assert.AreEqual(weightF, weightG, "Items F and G were used in the last 10 commands"); + Assert.AreEqual(weightG, weightH, "Items G and H were used in the last 10 commands"); + Assert.AreEqual(weightH, weightI, "Items H and I were used in the last 10 commands"); + Assert.AreEqual(weightI, weightJ, "Items I and J were used in the last 10 commands"); + Assert.AreEqual(weightJ, weightK, "Items J and K were used in the last 10 commands"); + + // Items up to the 15th should be in the third bucket + var weightL = history.GetCommandHistoryWeight("idL"); + var weightM = history.GetCommandHistoryWeight("idM"); + var weightN = history.GetCommandHistoryWeight("idN"); + var weightO = history.GetCommandHistoryWeight("idO"); + var weight15 = history.GetCommandHistoryWeight("id15"); + Assert.AreEqual(weightL, weightM, "Items L and M were used in the last 15 commands"); + Assert.AreEqual(weightM, weightN, "Items M and N were used in the last 15 commands"); + Assert.AreEqual(weightN, weightO, "Items N and O were used in the last 15 commands"); + Assert.AreEqual(weightO, weight15, "Items O and 15 were used in the last 15 commands"); + + // Items after that should be in the lowest buckets + var weight0 = history.GetCommandHistoryWeight(items[0].Id); + var weight3 = history.GetCommandHistoryWeight(items[3].Id); + var weight11 = history.GetCommandHistoryWeight(items[11].Id); + var weight16 = history.GetCommandHistoryWeight("id16"); + var weight20 = history.GetCommandHistoryWeight("id20"); + var weight30 = history.GetCommandHistoryWeight("id30"); + var weight40 = history.GetCommandHistoryWeight("id40"); + var weight49 = history.GetCommandHistoryWeight("id49"); + + Assert.IsTrue(weight0 > weight3); + Assert.IsTrue(weight3 > weight11); + Assert.IsTrue(weight11 > weight16); + + Assert.AreEqual(weight16, weight20); + Assert.AreEqual(weight20, weight30); + Assert.IsTrue(weight30 > weight40); + Assert.AreEqual(weight40, weight49); + + // The 50th item has fallen out of the list now + var weight50 = history.GetCommandHistoryWeight("id50"); + Assert.AreEqual(0, weight50, "Item 50 should have fallen out of the history list"); + } + + [TestMethod] + public void ValidateSimpleScoring() + { + // Setup + var items = new List + { + new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0 + new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0 + new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0 + }; + + var history = CreateHistory(items.Reverse().ToList()); + + var scoreA = MainListPage.ScoreTopLevelItem("C", items[0], history); + var scoreB = MainListPage.ScoreTopLevelItem("C", items[1], history); + var scoreC = MainListPage.ScoreTopLevelItem("C", items[2], history); + + // Assert + // All of these equally match the query, and they're all in the same bucket, + // so they should all have the same score. + Assert.AreEqual(scoreA, scoreB, "Items A and B should have the same score"); + Assert.AreEqual(scoreB, scoreC, "Items B and C should have the same score"); + } + + private static List CreateMockHistoryItems() + { + var items = new List + { + new("Visual Studio 2022"), // #0 -> bucket 0 + new("Visual Studio Code"), // #1 -> bucket 0 + new("Explore Mastodon", GivenId: "social.mastodon.explore"), // #2 -> bucket 0 + new("Run commands", Subtitle: "Executes commands (e.g. ping, cmd)", GivenId: "com.microsoft.cmdpal.run"), // #3 -> bucket 1 + new("Windows Settings"), // #4 -> bucket 1 + new("Command Prompt"), // #5 -> bucket 1 + new("Terminal Canary"), // #6 -> bucket 1 + }; + return items; + } + + private static RecentCommandsManager CreateMockHistoryService(List? items = null) + { + var history = CreateHistory((items ?? CreateMockHistoryItems()).Reverse().ToList()); + return history; + } + + private sealed record ScoredItem(ListItemMock Item, int Score) + { + public string Title => Item.Title; + + public override string ToString() => $"[{Score}]{Title}"; + } + + private static IEnumerable TieScoresToMatches(List items, List scores) + { + if (items.Count != scores.Count) + { + throw new ArgumentException("Items and scores must have the same number of elements"); + } + + for (var i = 0; i < items.Count; i++) + { + yield return new ScoredItem(items[i], scores[i]); + } + } + + private static IEnumerable GetMatches(IEnumerable scoredItems) + { + var matches = scoredItems + .Where(x => x.Score > 0) + .OrderByDescending(x => x.Score) + .ToList(); + + return matches; + } + + private static IEnumerable GetMatches(List items, List scores) + { + return GetMatches(TieScoresToMatches(items, scores)); + } + + [TestMethod] + public void ValidateScoredWeightingSimple() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + + var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, emptyHistory)).ToList(); + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList(); + Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items"); + for (var i = 0; i < unweightedScores.Count; i++) + { + var unweighted = unweightedScores[i]; + var weighted = weightedScores[i]; + var item = items[i]; + if (item.Title.Contains('C', System.StringComparison.CurrentCultureIgnoreCase)) + { + Assert.IsTrue(unweighted >= 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero"); + Assert.IsTrue(weighted > unweighted, $"Item {item.Title} should have a higher weighted ({weighted}) score than unweighted ({unweighted})"); + } + else + { + Assert.AreEqual(unweighted, 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero"); + Assert.AreEqual(unweighted, weighted); + } + } + + var unweightedMatches = GetMatches(items, unweightedScores).ToList(); + Assert.AreEqual(4, unweightedMatches.Count); + Assert.AreEqual("Command Prompt", unweightedMatches[0].Title, "Command Prompt should be the top match"); + Assert.AreEqual("Visual Studio Code", unweightedMatches[1].Title, "Visual Studio Code should be the second match"); + Assert.AreEqual("Terminal Canary", unweightedMatches[2].Title); + Assert.AreEqual("Run commands", unweightedMatches[3].Title); + + // Even after weighting for 1 use, Command Prompt should still be the top match. + var weightedMatches = GetMatches(items, weightedScores).ToList(); + Assert.AreEqual(4, weightedMatches.Count); + Assert.AreEqual("Command Prompt", weightedMatches[0].Title); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title); + Assert.AreEqual("Terminal Canary", weightedMatches[2].Title); + Assert.AreEqual("Run commands", weightedMatches[3].Title); + } + + [TestMethod] + public void ValidateTitlesAreMoreImportantThanHistory() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + + Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands"); + + // Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches + // the title better + Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match"); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal"); + Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle"); + } + + [TestMethod] + public void ValidateTitlesAreMoreImportantThanUsage() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + + // Add extra uses of VS Code to try and push it above Terminal + for (var i = 0; i < 10; i++) + { + history.AddHistoryItem(items[1].Id); + } + + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + + Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands"); + + // Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches + // the title better + Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match"); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal"); + Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle"); + } + + [TestMethod] + public void ValidateUsageEventuallyHelps() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + + // We're gonna run this test and keep adding more uses of VS Code till + // it breaks past Command Prompt + var vsCodeId = items[1].Id; + for (var i = 0; i < 10; i++) + { + history.AddHistoryItem(vsCodeId); + + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + Assert.AreEqual(4, weightedMatches.Count); + + var expectedCmdIndex = i < 5 ? 0 : 1; + var expectedCodeIndex = i < 5 ? 1 : 0; + Assert.AreEqual("Command Prompt", weightedMatches[expectedCmdIndex].Title); + Assert.AreEqual("Visual Studio Code", weightedMatches[expectedCodeIndex].Title); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs index 78cd82062a..7fe4e5281d 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs @@ -49,8 +49,8 @@ public class BasicTests : CommandPaletteTestBase { SetSearchBox("time and date"); - var searchFileItem = this.Find("Time and Date"); - Assert.AreEqual(searchFileItem.Name, "Time and Date"); + var searchFileItem = this.Find("Time and date"); + Assert.AreEqual(searchFileItem.Name, "Time and date"); searchFileItem.DoubleClick(); SetTimeAndDaterExtensionSearchBox("year"); @@ -63,8 +63,8 @@ public class BasicTests : CommandPaletteTestBase { SetSearchBox("Windows Terminal"); - var searchFileItem = this.Find("Open Windows Terminal Profiles"); - Assert.AreEqual(searchFileItem.Name, "Open Windows Terminal Profiles"); + var searchFileItem = this.Find("Open Windows Terminal profiles"); + Assert.AreEqual(searchFileItem.Name, "Open Windows Terminal profiles"); searchFileItem.DoubleClick(); // SetSearchBox("PowerShell"); @@ -74,10 +74,10 @@ public class BasicTests : CommandPaletteTestBase [TestMethod] public void BasicWindowsSettingsTest() { - SetSearchBox("Windows Settings"); + SetSearchBox("Windows settings"); - var searchFileItem = this.Find("Windows Settings"); - Assert.AreEqual(searchFileItem.Name, "Windows Settings"); + var searchFileItem = this.Find("Windows settings"); + Assert.AreEqual(searchFileItem.Name, "Windows settings"); searchFileItem.DoubleClick(); SetSearchBox("power"); diff --git a/src/modules/cmdpal/custom.props b/src/modules/cmdpal/custom.props index cf04c2de38..2cfa0bbf4b 100644 --- a/src/modules/cmdpal/custom.props +++ b/src/modules/cmdpal/custom.props @@ -5,7 +5,7 @@ true 2025 0 - 5 + 7 Microsoft Command Palette diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs index 84d915f540..7232e955d7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System; -using System.Linq; +using System.Collections.Generic; using Microsoft.CmdPal.Ext.Apps.Properties; using Microsoft.CmdPal.Ext.Apps.State; using Microsoft.CommandPalette.Extensions; @@ -35,7 +35,6 @@ public partial class AllAppsCommandProvider : CommandProvider _listItem = new(_page) { - Subtitle = Resources.search_installed_apps, MoreCommands = [new CommandContextItem(AllAppsSettings.Instance.Settings.SettingsPage)], }; @@ -51,46 +50,68 @@ public partial class AllAppsCommandProvider : CommandProvider if (limitSetting is null) { - return -1; + return 10; } - var quantity = -1; + var quantity = 10; if (int.TryParse(limitSetting, out var result)) { - quantity = result; + quantity = result < 0 ? quantity : result; } return quantity; } } - public override ICommandItem[] TopLevelCommands() => [_listItem, .._page.GetPinnedApps()]; + public override ICommandItem[] TopLevelCommands() => [_listItem, .. _page.GetPinnedApps()]; public ICommandItem? LookupApp(string displayName) { var items = _page.GetItems(); - // We're going to do this search in two directions: - // First, is this name a substring of any app... - var nameMatches = items.Where(i => i.Title.Contains(displayName)); + var nameMatches = new List(); + ICommandItem? bestAppMatch = null; + var bestLength = -1; - // ... Then, does any app have this name as a substring ... - // Only get one of these - "Terminal Preview" contains both "Terminal" and "Terminal Preview", so just take the best one - var appMatches = items.Where(i => displayName.Contains(i.Title)).OrderByDescending(i => i.Title.Length).Take(1); + foreach (var item in items) + { + if (item.Title is null) + { + continue; + } + + // We're going to do this search in two directions: + // First, is this name a substring of any app... + if (item.Title.Contains(displayName)) + { + nameMatches.Add(item); + } + + // ... Then, does any app have this name as a substring ... + // Only get one of these - "Terminal Preview" contains both "Terminal" and "Terminal Preview", so just take the best one + if (displayName.Contains(item.Title)) + { + if (item.Title.Length > bestLength) + { + bestLength = item.Title.Length; + bestAppMatch = item; + } + } + } // ... Now, combine those two - var both = nameMatches.Concat(appMatches); + List both = bestAppMatch is null ? nameMatches : [.. nameMatches, bestAppMatch]; - if (both.Count() == 1) + if (both.Count == 1) { - return both.First(); + return both[0]; } - else if (nameMatches.Count() == 1 && appMatches.Count() == 1) + else if (nameMatches.Count == 1 && bestAppMatch is not null) { - if (nameMatches.First() == appMatches.First()) + if (nameMatches[0] == bestAppMatch) { - return nameMatches.First(); + return nameMatches[0]; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs index 68b77ce728..2a264f70c2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading; using System.Threading.Tasks; using ManagedCommon; @@ -62,7 +61,9 @@ public sealed partial class AllAppsPage : ListPage { // Build or update the list if needed BuildListItems(); - return pinnedApps.Concat(unpinnedApps).ToArray(); + + AppListItem[] allApps = [.. pinnedApps, .. unpinnedApps]; + return allApps; } private void BuildListItems() @@ -93,16 +94,25 @@ public sealed partial class AllAppsPage : ListPage private AppItem[] GetAllApps() { - var uwpResults = _appCache.UWPs - .Where((application) => application.Enabled) - .Select(app => app.ToAppItem()); + List allApps = new(); - var win32Results = _appCache.Win32s - .Where((application) => application.Enabled && application.Valid) - .Select(app => app.ToAppItem()); + foreach (var uwpApp in _appCache.UWPs) + { + if (uwpApp.Enabled) + { + allApps.Add(uwpApp.ToAppItem()); + } + } - var allApps = uwpResults.Concat(win32Results).ToArray(); - return allApps; + foreach (var win32App in _appCache.Win32s) + { + if (win32App.Enabled && win32App.Valid) + { + allApps.Add(win32App.ToAppItem()); + } + } + + return [.. allApps]; } internal (AppItem[] AllApps, AppListItem[] PinnedItems, AppListItem[] UnpinnedItems) GetPrograms() @@ -118,9 +128,7 @@ public sealed partial class AllAppsPage : ListPage if (isPinned) { - appListItem.Tags = appListItem.Tags - .Concat([new Tag() { Icon = Icons.PinIcon }]) - .ToArray(); + appListItem.Tags = [.. appListItem.Tags, new Tag() { Icon = Icons.PinIcon }]; pinned.Add(appListItem); } else @@ -129,15 +137,14 @@ public sealed partial class AllAppsPage : ListPage } } + pinned.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal)); + unpinned.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal)); + return ( - allApps - .ToArray(), - pinned - .OrderBy(app => app.Title) - .ToArray(), - unpinned - .OrderBy(app => app.Title) - .ToArray()); + allApps, + pinned.ToArray(), + unpinned.ToArray() + ); } private void OnPinStateChanged(object? sender, PinStateChangedEventArgs e) @@ -147,44 +154,55 @@ public sealed partial class AllAppsPage : ListPage * So, instead, we'll just compare pinned items to move existing * items between the two lists. */ - var existingAppItem = allApps.FirstOrDefault(f => f.AppIdentifier == e.AppIdentifier); + AppItem? existingAppItem = null; + + foreach (var app in allApps) + { + if (app.AppIdentifier == e.AppIdentifier) + { + existingAppItem = app; + break; + } + } if (existingAppItem is not null) { var appListItem = new AppListItem(existingAppItem, true, e.IsPinned); + var newPinned = new List(pinnedApps); + var newUnpinned = new List(unpinnedApps); + if (e.IsPinned) { - // Remove it from the unpinned apps array - this.unpinnedApps = this.unpinnedApps - .Where(app => app.AppIdentifier != existingAppItem.AppIdentifier) - .OrderBy(app => app.Title) - .ToArray(); - - var newPinned = this.pinnedApps.ToList(); newPinned.Add(appListItem); - this.pinnedApps = newPinned - .OrderBy(app => app.Title) - .ToArray(); + foreach (var app in newUnpinned) + { + if (app.AppIdentifier == e.AppIdentifier) + { + newUnpinned.Remove(app); + break; + } + } } else { - // Remove it from the pinned apps array - this.pinnedApps = this.pinnedApps - .Where(app => app.AppIdentifier != existingAppItem.AppIdentifier) - .OrderBy(app => app.Title) - .ToArray(); - - var newUnpinned = this.unpinnedApps.ToList(); newUnpinned.Add(appListItem); - this.unpinnedApps = newUnpinned - .OrderBy(app => app.Title) - .ToArray(); + foreach (var app in newPinned) + { + if (app.AppIdentifier == e.AppIdentifier) + { + newPinned.Remove(app); + break; + } + } } - RaiseItemsChanged(0); + pinnedApps = newPinned.ToArray(); + unpinnedApps = newUnpinned.ToArray(); } + + RaiseItemsChanged(0); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs index 320501fcdc..bf326221f9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs @@ -18,16 +18,12 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; - private static string Experimental(string propertyName) => $"{_namespace}.experimental.{propertyName}"; - private static readonly List _searchResultLimitChoices = [ - new ChoiceSetSetting.Choice(Resources.limit_none, "-1"), new ChoiceSetSetting.Choice(Resources.limit_0, "0"), new ChoiceSetSetting.Choice(Resources.limit_1, "1"), new ChoiceSetSetting.Choice(Resources.limit_5, "5"), new ChoiceSetSetting.Choice(Resources.limit_10, "10"), - new ChoiceSetSetting.Choice(Resources.limit_20, "20"), ]; #pragma warning disable SA1401 // Fields should be private diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs index 48beaec1ff..f2476dae61 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.Ext.Apps.Storage; @@ -31,7 +30,10 @@ public sealed partial class AppCache : IAppCache, IDisposable public AppCache() { _win32ProgramRepositoryHelper = new Win32ProgramFileSystemWatchers(); - _win32ProgramRepository = new Win32ProgramRepository(_win32ProgramRepositoryHelper.FileSystemWatchers.Cast().ToList(), AllAppsSettings.Instance, _win32ProgramRepositoryHelper.PathsToWatch); + + var watchers = new List(_win32ProgramRepositoryHelper.FileSystemWatchers); + + _win32ProgramRepository = new Win32ProgramRepository(watchers, AllAppsSettings.Instance, _win32ProgramRepositoryHelper.PathsToWatch); _packageRepository = new PackageRepository(new PackageCatalogWrapper()); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCommand.cs index 39e71f9a32..5fadf89bd6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCommand.cs @@ -16,24 +16,19 @@ using WyHash; namespace Microsoft.CmdPal.Ext.Apps; -public sealed partial class AppCommand : InvokableCommand +internal sealed partial class AppCommand : InvokableCommand { private readonly AppItem _app; public AppCommand(AppItem app) { _app = app; - - Name = Resources.run_command_action; + Name = Resources.run_command_action!; Id = GenerateId(); - - if (!string.IsNullOrEmpty(app.IcoPath)) - { - Icon = new(app.IcoPath); - } + Icon = Icons.GenericAppIcon; } - internal static async Task StartApp(string aumid) + private static async Task StartApp(string aumid) { await Task.Run(() => { @@ -58,7 +53,7 @@ public sealed partial class AppCommand : InvokableCommand }).ConfigureAwait(false); } - internal static async Task StartExe(string path) + private static async Task StartExe(string path) { await Task.Run(() => { @@ -73,7 +68,7 @@ public sealed partial class AppCommand : InvokableCommand }); } - internal async Task Launch() + private async Task Launch() { if (_app.IsPackaged) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs index d91c195552..e99ffae352 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs @@ -5,34 +5,48 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Ext.Apps.Commands; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Storage.Streams; namespace Microsoft.CmdPal.Ext.Apps.Programs; -internal sealed partial class AppListItem : ListItem +public sealed partial class AppListItem : ListItem { + private readonly AppCommand _appCommand; private readonly AppItem _app; - private static readonly Tag _appTag = new("App"); - private readonly Lazy
_details; - private readonly Lazy _icon; + private readonly Lazy> _iconLoadTask; + + private InterlockedBoolean _isLoadingIcon; public override IDetails? Details { get => _details.Value; set => base.Details = value; } - public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } + public override IIconInfo? Icon + { + get + { + if (_isLoadingIcon.Set()) + { + _ = LoadIconAsync(); + } + + return base.Icon; + } + set => base.Icon = value; + } public string AppIdentifier => _app.AppIdentifier; public AppListItem(AppItem app, bool useThumbnails, bool isPinned) - : base(new AppCommand(app)) { + Command = _appCommand = new AppCommand(app); _app = app; Title = app.Name; Subtitle = app.Subtitle; - Tags = [_appTag]; + Icon = Icons.GenericAppIcon; MoreCommands = AddPinCommands(_app.Commands!, isPinned); @@ -43,12 +57,19 @@ internal sealed partial class AppListItem : ListItem return t.Result; }); - _icon = new Lazy(() => + _iconLoadTask = new Lazy>(async () => await FetchIcon(useThumbnails)); + } + + private async Task LoadIconAsync() + { + try { - var t = FetchIcon(useThumbnails); - t.Wait(); - return t.Result; - }); + Icon = _appCommand.Icon = await _iconLoadTask.Value ?? Icons.GenericAppIcon; + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to load icon for {AppIdentifier}\n{ex}"); + } } private async Task
BuildDetails() @@ -87,12 +108,12 @@ internal sealed partial class AppListItem : ListItem return new Details() { Title = this.Title, - HeroImage = heroImage ?? this.Icon ?? new IconInfo(string.Empty), + HeroImage = heroImage ?? this.Icon ?? Icons.GenericAppIcon, Metadata = metadata.ToArray(), }; } - public async Task FetchIcon(bool useThumbnails) + private async Task FetchIcon(bool useThumbnails) { IconInfo? icon = null; if (_app.IsPackaged) @@ -108,12 +129,12 @@ internal sealed partial class AppListItem : ListItem var stream = await ThumbnailHelper.GetThumbnail(_app.ExePath); if (stream is not null) { - var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); - icon = new IconInfo(data, data); + icon = IconInfo.FromStream(stream); } } - catch + catch (Exception ex) { + Logger.LogDebug($"Failed to load icon for {AppIdentifier}:\n{ex}"); } icon = icon ?? new IconInfo(_app.IcoPath); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/OpenInConsoleCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/OpenInConsoleCommand.cs deleted file mode 100644 index 2bb8d421d4..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/OpenInConsoleCommand.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps.Commands; - -internal sealed partial class OpenInConsoleCommand : InvokableCommand -{ - private readonly string _target; - - public OpenInConsoleCommand(string target) - { - Name = Resources.open_path_in_console; - Icon = Icons.OpenPathIcon; - - _target = target; - } - - internal static async Task LaunchTarget(string t) - { - await Task.Run(() => - { - try - { - var processStartInfo = new ProcessStartInfo - { - WorkingDirectory = t, - FileName = "cmd.exe", - }; - - Process.Start(processStartInfo); - } - catch (Exception ex) - { - Logger.LogError(ex.Message); - } - }); - } - - public override CommandResult Invoke() - { - _ = LaunchTarget(_target).ConfigureAwait(false); - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/OpenPathCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/OpenPathCommand.cs deleted file mode 100644 index f4c8dde29e..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/OpenPathCommand.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics; -using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps.Commands; - -internal sealed partial class OpenPathCommand : InvokableCommand -{ - private readonly string _target; - - public OpenPathCommand(string target) - { - Name = Resources.open_location; - Icon = Icons.OpenPathIcon; - - _target = target; - } - - internal static async Task LaunchTarget(string t) - { - await Task.Run(() => - { - Process.Start(new ProcessStartInfo(t) { UseShellExecute = true }); - }); - } - - public override CommandResult Invoke() - { - _ = LaunchTarget(_target).ConfigureAwait(false); - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs index 0a84230a88..b6e2b94ef5 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using Microsoft.CmdPal.Ext.Apps.Programs; namespace Microsoft.CmdPal.Ext.Apps; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs index 5f4a3e7a92..47e012dcc2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs @@ -6,21 +6,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Apps; -internal sealed class Icons +internal static class Icons { - internal static IconInfo AllAppsIcon => IconHelpers.FromRelativePath("Assets\\AllApps.svg"); + internal static IconInfo AllAppsIcon { get; } = IconHelpers.FromRelativePath("Assets\\AllApps.svg"); - internal static IconInfo RunAsUserIcon => new("\uE7EE"); // OtherUser icon + internal static IconInfo RunAsUserIcon { get; } = new("\uE7EE"); // OtherUser icon - internal static IconInfo RunAsAdminIcon => new("\uE7EF"); // Admin icon + internal static IconInfo RunAsAdminIcon { get; } = new("\uE7EF"); // Admin icon - internal static IconInfo OpenPathIcon => new("\ue838"); // Folder Open icon + internal static IconInfo OpenPathIcon { get; } = new("\ue838"); // Folder Open icon - internal static IconInfo CopyIcon => new("\ue8c8"); // Copy icon + internal static IconInfo CopyIcon { get; } = new("\ue8c8"); // Copy icon public static IconInfo UnpinIcon { get; } = new("\uE77A"); // Unpin icon public static IconInfo PinIcon { get; } = new("\uE840"); // Pin icon public static IconInfo UninstallApplicationIcon { get; } = new("\uE74D"); // Uninstall icon + + public static IconInfo GenericAppIcon { get; } = new("\uE737"); // Favicon } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs index 5a11d9c135..0db868222c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs @@ -2,12 +2,8 @@ // 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.Linq; -using System.Text; using System.Text.Json.Serialization; -using System.Threading.Tasks; using Microsoft.CmdPal.Ext.Apps.State; namespace Microsoft.CmdPal.Ext.Apps; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/KeyChords.cs index 6b4063f35d..14ca0cf1c7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/KeyChords.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/KeyChords.cs @@ -2,7 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CmdPal.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.Ext.Apps; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/LocalSuppressions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/LocalSuppressions.cs new file mode 100644 index 0000000000..87a0cf5673 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/LocalSuppressions.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.SysFreeStringSafeHandle")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj index fb074407f8..e59cdfd7e9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj @@ -25,7 +25,7 @@ - + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs index 38337c4462..79a7ee14fc 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; -using System.Linq; using System.Security.Principal; using Windows.Management.Deployment; @@ -26,9 +25,19 @@ public class PackageManagerWrapper : IPackageManager { var pkgs = _packageManager.FindPackagesForUser(user.Value); - return pkgs.Select(PackageWrapper.GetWrapperFromPackage).Where(package => package is not null); + ICollection packages = []; + + foreach (var package in pkgs) + { + if (package is not null) + { + packages.Add(PackageWrapper.GetWrapperFromPackage(package)); + } + } + + return packages; } - return Enumerable.Empty(); + return []; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs index 01a518f057..faca2c2b39 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO.Abstractions; -using System.Linq; using System.Threading.Tasks; using System.Xml.Linq; using ManagedCommon; @@ -72,18 +71,20 @@ public partial class UWP PInvoke.SHCreateStreamOnFileEx(path, STGMREAD, noAttribute, false, null, &stream).ThrowOnFailure(); using var streamHandle = new SafeComHandle((IntPtr)stream); - Apps = AppxPackageHelper.GetAppsFromManifest(stream).Select(appInManifest => + var appsInManifest = AppxPackageHelper.GetAppsFromManifest(stream); + + foreach (var appInManifest in appsInManifest) { using var appHandle = new SafeComHandle(appInManifest); - return new UWPApplication((IAppxManifestApplication*)appInManifest, this); - }).Where(a => - { - var valid = - !string.IsNullOrEmpty(a.UserModelId) && - !string.IsNullOrEmpty(a.DisplayName) && - a.AppListEntry != "none"; - return valid; - }).ToList(); + var uwpApp = new UWPApplication((IAppxManifestApplication*)appInManifest, this); + + if (!string.IsNullOrEmpty(uwpApp.UserModelId) && + !string.IsNullOrEmpty(uwpApp.DisplayName) && + uwpApp.AppListEntry != "none") + { + Apps.Add(uwpApp); + } + } } catch (Exception ex) { @@ -93,19 +94,31 @@ public partial class UWP } } - // http://www.hanselman.com/blog/GetNamespacesFromAnXMLDocumentWithXPathDocumentAndLINQToXML.aspx private static string[] XmlNamespaces(string path) { var z = XDocument.Load(path); if (z.Root is not null) { - var namespaces = z.Root.Attributes(). - Where(a => a.IsNamespaceDeclaration). - GroupBy( - a => a.Name.Namespace == XNamespace.None ? string.Empty : a.Name.LocalName, - a => XNamespace.Get(a.Value)).Select( - g => g.First().ToString()).ToArray(); - return namespaces; + var namespaces = new HashSet(); + + var attributes = z.Root.Attributes(); + foreach (var attribute in attributes) + { + if (attribute.IsNamespaceDeclaration) + { + // Extract namespace + var key = attribute.Name.Namespace == XNamespace.None ? string.Empty : attribute.Name.LocalName; + XNamespace ns = XNamespace.Get(attribute.Value); + var nsString = ns.ToString(); + + // Use HashSet to check for duplicates + namespaces.Add(nsString); + } + } + + var uniqueNamespaces = new string[namespaces.Count]; + namespaces.CopyTo(uniqueNamespaces); + return uniqueNamespaces; } else { @@ -115,10 +128,13 @@ public partial class UWP private void InitPackageVersion(string[] namespaces) { - foreach (var n in _versionFromNamespace.Keys.Where(namespaces.Contains)) + foreach (var n in _versionFromNamespace.Keys) { - Version = _versionFromNamespace[n]; - return; + if (Array.IndexOf(namespaces, n) >= 0) + { + Version = _versionFromNamespace[n]; + return; + } } Version = PackageVersion.Unknown; @@ -137,7 +153,18 @@ public partial class UWP foreach (var app in u.Apps) { - if (AllAppsSettings.Instance.DisabledProgramSources.All(x => x.UniqueIdentifier != app.UniqueIdentifier)) + var isDisabled = false; + + foreach (var disabled in AllAppsSettings.Instance.DisabledProgramSources) + { + if (disabled.UniqueIdentifier == app.UniqueIdentifier) + { + isDisabled = true; + break; + } + } + + if (!isDisabled) { appsBag.Add(app); } @@ -154,20 +181,28 @@ public partial class UWP private static IEnumerable CurrentUserPackages() { - return PackageManagerWrapper.FindPackagesForCurrentUser().Where(p => + var currentUsersPackages = PackageManagerWrapper.FindPackagesForCurrentUser(); + ICollection packagesToReturn = []; + + foreach (var pkg in currentUsersPackages) { try { - var f = p.IsFramework; - var path = p.InstalledLocation; - return !f && !string.IsNullOrEmpty(path); + var f = pkg.IsFramework; + var path = pkg.InstalledLocation; + + if (!f && !string.IsNullOrEmpty(path)) + { + packagesToReturn.Add(pkg); + } } catch (Exception ex) { Logger.LogError(ex.Message); - return false; } - }); + } + + return packagesToReturn; } public override string ToString() diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs index 525e5b3ca8..37698d972d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.IO.Abstractions; -using System.Linq; using System.Xml; using ManagedCommon; using Microsoft.CmdPal.Ext.Apps.Commands; @@ -95,16 +94,17 @@ public class UWPApplication : IUWPApplication commands.Add( new CommandContextItem( - new CopyTextCommand(Location) { Name = Resources.copy_path }) + new CopyPathCommand(Location)) { RequestedShortcut = KeyChords.CopyFilePath, }); commands.Add( new CommandContextItem( - new OpenPathCommand(Location) + new OpenFileCommand(Location) { - Name = Resources.open_containing_folder, + Icon = new("\uE838"), + Name = Resources.open_location, }) { RequestedShortcut = KeyChords.OpenFileLocation, @@ -348,20 +348,22 @@ public class UWPApplication : IUWPApplication // // FirstOrDefault would result in us using the 1x scaled icon // always, which is usually too small for our needs. - var selectedIconPath = paths.LastOrDefault(File.Exists); - if (!string.IsNullOrEmpty(selectedIconPath)) + for (var i = paths.Count - 1; i >= 0; i--) { - LogoPath = selectedIconPath; - if (highContrast) + if (File.Exists(paths[i])) { - LogoType = LogoType.HighContrast; - } - else - { - LogoType = LogoType.Colored; - } + LogoPath = paths[i]; + if (highContrast) + { + LogoType = LogoType.HighContrast; + } + else + { + LogoType = LogoType.Colored; + } - return true; + return true; + } } } @@ -402,7 +404,23 @@ public class UWPApplication : IUWPApplication } } - var selectedIconPath = paths.OrderBy(x => Math.Abs(pathFactorPairs.GetValueOrDefault(x) - appIconSize)).FirstOrDefault(File.Exists); + // Sort paths by distance to desired app icon size + var selectedIconPath = string.Empty; + var closestDistance = int.MaxValue; + + foreach (var p in paths) + { + if (File.Exists(p) && pathFactorPairs.TryGetValue(p, out var factor)) + { + var distance = Math.Abs(factor - appIconSize); + if (distance < closestDistance) + { + closestDistance = distance; + selectedIconPath = p; + } + } + } + if (!string.IsNullOrEmpty(selectedIconPath)) { LogoPath = selectedIconPath; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs index 151a6ee6d7..9b89afc425 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs @@ -7,7 +7,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.IO.Abstractions; -using System.Linq; using System.Security; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -25,7 +24,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Programs; [Serializable] public class Win32Program : IProgram { - public static readonly Win32Program InvalidProgram = new Win32Program { Valid = false, Enabled = false }; + public static readonly Win32Program InvalidProgram = new() { Valid = false, Enabled = false }; private static readonly IFileSystem FileSystem = new FileSystem(); private static readonly IPath Path = FileSystem.Path; @@ -84,7 +83,7 @@ public class Win32Program : IProgram private const string ShortcutExtension = "lnk"; private const string ApplicationReferenceExtension = "appref-ms"; private const string InternetShortcutExtension = "url"; - private static readonly HashSet ExecutableApplicationExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { "exe", "bat", "bin", "com", "cpl", "msc", "msi", "cmd", "ps1", "job", "msp", "mst", "sct", "ws", "wsh", "wsf" }; + private static readonly HashSet ExecutableApplicationExtensions = new(StringComparer.OrdinalIgnoreCase) { "exe", "bat", "bin", "com", "cpl", "msc", "msi", "cmd", "ps1", "job", "msp", "mst", "sct", "ws", "wsh", "wsf" }; private const string ProxyWebApp = "_proxy.exe"; private const string AppIdArgument = "--app-id"; @@ -202,13 +201,16 @@ public class Win32Program : IProgram } commands.Add(new CommandContextItem( - new CopyTextCommand(FullPath) { Name = Resources.copy_path }) + new CopyPathCommand(FullPath)) { RequestedShortcut = KeyChords.CopyFilePath, }); commands.Add(new CommandContextItem( - new OpenPathCommand(ParentDirectory)) + new ShowFileInFolderCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath) + { + Name = Resources.open_location, + }) { RequestedShortcut = KeyChords.OpenFileLocation, }); @@ -282,7 +284,7 @@ public class Win32Program : IProgram } } - private static readonly Regex InternetShortcutURLPrefixes = new Regex(@"^steam:\/\/(rungameid|run|open)\/|^com\.epicgames\.launcher:\/\/apps\/", RegexOptions.Compiled); + private static readonly Regex InternetShortcutURLPrefixes = new(@"^steam:\/\/(rungameid|run|open)\/|^com\.epicgames\.launcher:\/\/apps\/", RegexOptions.Compiled); // This function filters Internet Shortcut programs private static Win32Program InternetShortcutProgram(string path) @@ -616,9 +618,24 @@ public class Win32Program : IProgram } private static IEnumerable CustomProgramPaths(IEnumerable sources, IList suffixes) - => sources?.Where(programSource => Directory.Exists(programSource.Location) && programSource.Enabled) - .SelectMany(programSource => ProgramPaths(programSource.Location, suffixes)) - .ToList() ?? Enumerable.Empty(); + { + if (sources is not null) + { + var paths = new List(); + + foreach (var programSource in sources) + { + if (Directory.Exists(programSource.Location) && programSource.Enabled) + { + paths.AddRange(ProgramPaths(programSource.Location, suffixes)); + } + } + + return paths; + } + + return []; + } // Function to obtain the list of applications, the locations of which have been added to the env variable PATH private static List PathEnvironmentProgramPaths(IList suffixes) @@ -647,9 +664,15 @@ public class Win32Program : IProgram } private static List IndexPath(IList suffixes, List indexLocations) - => indexLocations - .SelectMany(indexLocation => ProgramPaths(indexLocation, suffixes)) - .ToList(); + { + var paths = new List(); + foreach (var indexLocation in indexLocations) + { + paths.AddRange(ProgramPaths(indexLocation, suffixes)); + } + + return paths; + } private static List StartMenuProgramPaths(IList suffixes) { @@ -691,17 +714,51 @@ public class Win32Program : IProgram } } - return paths - .Where(path => suffixes.Any(suffix => path.EndsWith(suffix, StringComparison.InvariantCultureIgnoreCase))) - .Select(ExpandEnvironmentVariables) - .Where(path => path is not null) - .ToList(); + var returnedPaths = new List(); + foreach (var path in paths) + { + var matchesSuffix = false; + foreach (var suffix in suffixes) + { + if (path.EndsWith(suffix, StringComparison.InvariantCultureIgnoreCase)) + { + matchesSuffix = true; + break; + } + } + + if (matchesSuffix) + { + var expandedPath = ExpandEnvironmentVariables(path); + if (expandedPath is not null) + { + returnedPaths.Add(expandedPath); + } + } + } + + return returnedPaths; } private static IEnumerable GetPathsFromRegistry(RegistryKey root) - => root - .GetSubKeyNames() - .Select(x => GetPathFromRegistrySubkey(root, x)); + { + var result = new List(); + + // Get all subkey names + var subKeyNames = root.GetSubKeyNames(); + + // Process each subkey to extract the path + foreach (var subkeyName in subKeyNames) + { + var path = GetPathFromRegistrySubkey(root, subkeyName); + if (!string.IsNullOrEmpty(path)) + { + result.Add(path); + } + } + + return result; + } private static string GetPathFromRegistrySubkey(RegistryKey root, string subkey) { @@ -748,16 +805,13 @@ public class Win32Program : IProgram private sealed class Win32ProgramEqualityComparer : IEqualityComparer { - public static readonly Win32ProgramEqualityComparer Default = new Win32ProgramEqualityComparer(); + public static readonly Win32ProgramEqualityComparer Default = new(); public bool Equals(Win32Program? app1, Win32Program? app2) { - if (app1 is null && app2 is null) - { - return true; - } - - return app1 is not null + return app1 is null && app2 is null + ? true + : app1 is not null && app2 is not null && (app1.Name?.ToUpperInvariant(), app1.ExecutableName?.ToUpperInvariant(), app1.FullPath?.ToUpperInvariant()) .Equals((app2.Name?.ToUpperInvariant(), app2.ExecutableName?.ToUpperInvariant(), app2.FullPath?.ToUpperInvariant())); @@ -768,7 +822,28 @@ public class Win32Program : IProgram } public static List DeduplicatePrograms(IEnumerable programs) - => new HashSet(programs, Win32ProgramEqualityComparer.Default).ToList(); + { + // Create a HashSet with the custom equality comparer to automatically deduplicate programs + var uniquePrograms = new HashSet(Win32ProgramEqualityComparer.Default); + + // Filter out invalid programs and add valid ones to the HashSet + foreach (var program in programs) + { + if (program?.Valid == true) + { + uniquePrograms.Add(program); + } + } + + // Convert the HashSet to a List for return + var result = new List(uniquePrograms.Count); + foreach (var program in uniquePrograms) + { + result.Add(program); + } + + return result; + } private static Win32Program GetProgramFromPath(string path) { @@ -884,8 +959,22 @@ public class Win32Program : IProgram foreach (var path in source.GetPaths()) { - if (disabledProgramsList.All(x => x.UniqueIdentifier != path) && - !ExecutableApplicationExtensions.Contains(Extension(path))) + if (ExecutableApplicationExtensions.Contains(Extension(path))) + { + continue; + } + + var isDisabled = false; + foreach (var disabledProgram in disabledProgramsList) + { + if (disabledProgram.UniqueIdentifier == path) + { + isDisabled = true; + break; + } + } + + if (!isDisabled) { pathBag.Add(path); } @@ -905,7 +994,17 @@ public class Win32Program : IProgram foreach (var path in source.GetPaths()) { - if (disabledProgramsList.All(x => x.UniqueIdentifier != path)) + var isDisabled = false; + foreach (var disabledProgram in disabledProgramsList) + { + if (disabledProgram.UniqueIdentifier == path) + { + isDisabled = true; + break; + } + } + + if (!isDisabled) { runCommandPathBag.Add(path); } @@ -934,10 +1033,8 @@ public class Win32Program : IProgram } }); - var programs = programsList.ToList(); - var runCommandPrograms = runCommandProgramsList.ToList(); - - return DeduplicatePrograms(programs.Concat(runCommandPrograms).Where(program => program?.Valid == true)); + List allPrograms = [.. programsList, .. runCommandProgramsList]; + return DeduplicatePrograms(allPrograms); } catch (Exception e) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs index d00d51a728..9fdf833b0a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs @@ -61,7 +61,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// - /// Looks up a localized string similar to All Apps. + /// Looks up a localized string similar to Search apps. /// internal static string all_apps { get { @@ -241,7 +241,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// - /// Looks up a localized string similar to Open location. + /// Looks up a localized string similar to Open file location. /// internal static string open_location { get { @@ -313,16 +313,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// - /// Looks up a localized string similar to Search installed apps. - /// - internal static string search_installed_apps { - get { - return ResourceManager.GetString("search_installed_apps", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Search installed apps.... + /// Looks up a localized string similar to Search apps.... /// internal static string search_installed_apps_placeholder { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx index 4191efddd5..758c61e20f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx @@ -124,14 +124,11 @@ Installed apps - - Search installed apps - - All Apps + Search apps - Search installed apps... + Search apps... Open path in console @@ -161,7 +158,7 @@ File - Open location + Open file location Copy path @@ -237,4 +234,4 @@ Unlimited - + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinStateChangedEventArgs.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinStateChangedEventArgs.cs index 75cbbde56d..7590ba3ad9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinStateChangedEventArgs.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinStateChangedEventArgs.cs @@ -3,10 +3,6 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.CmdPal.Ext.Apps.State; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs index 4540caf78d..0fdc0a934c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs @@ -3,9 +3,7 @@ // See the LICENSE file in the project root for more information. using System; -using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using ManagedCommon; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -31,7 +29,7 @@ public sealed class PinnedAppsManager public bool IsAppPinned(string appIdentifier) { - return _pinnedApps.PinnedAppIdentifiers.Contains(appIdentifier, StringComparer.OrdinalIgnoreCase); + return _pinnedApps.PinnedAppIdentifiers.IndexOf(appIdentifier) >= 0; } public void PinApp(string appIdentifier) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs index b5f92db020..4c12ac0fc7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs @@ -6,7 +6,6 @@ using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; using ManagedCommon; namespace Microsoft.CmdPal.Ext.Apps.Storage; @@ -20,7 +19,16 @@ public class ListRepository : IRepository, IEnumerable { public IList Items { - get { return _items.Values.ToList(); } + get + { + var items = new List(_items.Count); + foreach (var item in _items.Values) + { + items.Add(item); + } + + return items; + } } private ConcurrentDictionary _items = new ConcurrentDictionary(); @@ -34,9 +42,16 @@ public class ListRepository : IRepository, IEnumerable // enforce that internal representation try { + var result = new ConcurrentDictionary(); + + foreach (var item in list) + { #pragma warning disable CS8602 // Dereference of a possibly null reference. - _items = new ConcurrentDictionary(list.ToDictionary(i => i.GetHashCode())); + result.TryAdd(item.GetHashCode(), item); #pragma warning restore CS8602 // Dereference of a possibly null reference. + } + + _items = result; } catch (ArgumentException ex) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs index 2c53a649b9..e9708899c8 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System; -using System.Linq; using ManagedCommon; using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.Ext.Apps.Utils; @@ -104,10 +103,14 @@ internal sealed partial class PackageRepository : ListRepository a.Package.Equals(uwp)).ToArray(); - foreach (var app in apps) + foreach (var app in Items) { + if (!app.Package.Equals(uwp)) + { + continue; + } + Remove(app); _isDirty = true; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs index 3e992cc807..b17568ea73 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using ManagedCommon; namespace Microsoft.CmdPal.Ext.Apps.Storage; @@ -55,7 +54,16 @@ internal sealed partial class Win32ProgramFileSystemWatchers : IDisposable } } - return paths.Except(invalidPaths).ToArray(); + var validPaths = new List(); + foreach (var path in paths) + { + if (!invalidPaths.Contains(path)) + { + validPaths.Add(path); + } + } + + return validPaths.ToArray(); } public void Dispose() diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs index d63afb2180..dbe7a694aa 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs @@ -2,10 +2,7 @@ // 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.Diagnostics; -using System.Text; -using System.Threading; namespace Microsoft.CmdPal.Ext.Apps.Utils; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs index f668fe7bf6..04fecb8379 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs @@ -2,12 +2,6 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.CmdPal.Ext.Apps.Utils; public enum Theme diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs index 005f1962f6..243e0e9a91 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs @@ -4,7 +4,6 @@ using System; using System.Globalization; -using System.Linq; using Microsoft.Win32; @@ -56,15 +55,25 @@ public static class ThemeHelper return Theme.Light; // Default to light theme if missing } - var theme = themePath.Split('\\').Last().Split('.').First().ToLowerInvariant(); - - return theme switch + var splitThemePath = themePath.Split('\\'); + if (splitThemePath.Length > 0) { - "hc1" => Theme.HighContrastOne, - "hc2" => Theme.HighContrastTwo, - "hcwhite" => Theme.HighContrastWhite, - "hcblack" => Theme.HighContrastBlack, - _ => Theme.Light, - }; + var lastSegment = splitThemePath[splitThemePath.Length - 1]; + var splitSegment = lastSegment.Split('.'); + if (splitSegment.Length > 0) + { + var themeVariant = splitSegment[0].ToLowerInvariant(); + return themeVariant switch + { + "hc1" => Theme.HighContrastOne, + "hc2" => Theme.HighContrastTwo, + "hcwhite" => Theme.HighContrastWhite, + "hcblack" => Theme.HighContrastBlack, + _ => Theme.Light, + }; + } + } + + return Theme.Light; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs deleted file mode 100644 index 93fc6d8d01..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Foundation; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class AddBookmarkForm : FormContent -{ - internal event TypedEventHandler? AddedCommand; - - private readonly BookmarkData? _bookmark; - - public AddBookmarkForm(BookmarkData? bookmark) - { - _bookmark = bookmark; - var name = _bookmark?.Name ?? string.Empty; - var url = _bookmark?.Bookmark ?? string.Empty; - TemplateJson = $$""" -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.5", - "body": [ - { - "type": "Input.Text", - "style": "text", - "id": "name", - "label": "{{Resources.bookmarks_form_name_label}}", - "value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}}, - "isRequired": true, - "errorMessage": "{{Resources.bookmarks_form_name_required}}" - }, - { - "type": "Input.Text", - "style": "text", - "id": "bookmark", - "value": {{JsonSerializer.Serialize(url, BookmarkSerializationContext.Default.String)}}, - "label": "{{Resources.bookmarks_form_bookmark_label}}", - "isRequired": true, - "errorMessage": "{{Resources.bookmarks_form_bookmark_required}}" - } - ], - "actions": [ - { - "type": "Action.Submit", - "title": "{{Resources.bookmarks_form_save}}", - "data": { - "name": "name", - "bookmark": "bookmark" - } - } - ] -} -"""; - } - - public override CommandResult SubmitForm(string payload) - { - var formInput = JsonNode.Parse(payload); - if (formInput is null) - { - return CommandResult.GoHome(); - } - - // get the name and url out of the values - var formName = formInput["name"] ?? string.Empty; - var formBookmark = formInput["bookmark"] ?? string.Empty; - var hasPlaceholder = formBookmark.ToString().Contains('{') && formBookmark.ToString().Contains('}'); - - var updated = _bookmark ?? new BookmarkData(); - updated.Name = formName.ToString(); - updated.Bookmark = formBookmark.ToString(); - - AddedCommand?.Invoke(this, updated); - return CommandResult.GoHome(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs deleted file mode 100644 index bf92a4413b..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Text.Json.Serialization; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -public class BookmarkData -{ - public string Name { get; set; } = string.Empty; - - public string Bookmark { get; set; } = string.Empty; - - // public string Type { get; set; } = string.Empty; - [JsonIgnore] - public bool IsPlaceholder => Bookmark.Contains('{') && Bookmark.Contains('}'); - - internal void GetExeAndArgs(out string exe, out string args) - { - ShellHelpers.ParseExecutableAndArgs(Bookmark, out exe, out args); - } - - internal bool IsWebUrl() - { - GetExeAndArgs(out var exe, out var args); - if (string.IsNullOrEmpty(exe)) - { - return false; - } - - if (Uri.TryCreate(exe, UriKind.Absolute, out var uri)) - { - if (uri.Scheme == Uri.UriSchemeFile) - { - return false; - } - - // return true if the scheme is http or https, or if there's no scheme (e.g., "www.example.com") but there is a dot in the host - return - uri.Scheme == Uri.UriSchemeHttp || - uri.Scheme == Uri.UriSchemeHttps || - (string.IsNullOrEmpty(uri.Scheme) && uri.Host.Contains('.')); - } - - // If we can't parse it as a URI, we assume it's not a web URL - return false; - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs deleted file mode 100644 index 965f42d1b0..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class BookmarkPlaceholderForm : FormContent -{ - private static readonly CompositeFormat ErrorMessage = System.Text.CompositeFormat.Parse(Resources.bookmarks_required_placeholder); - - private readonly List _placeholderNames; - - private readonly string _bookmark = string.Empty; - - // TODO pass in an array of placeholders - public BookmarkPlaceholderForm(string name, string url) - { - _bookmark = url; - var r = new Regex(Regex.Escape("{") + "(.*?)" + Regex.Escape("}")); - var matches = r.Matches(url); - _placeholderNames = matches.Select(m => m.Groups[1].Value).ToList(); - var inputs = _placeholderNames.Select(p => - { - var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, p); - return $$""" -{ - "type": "Input.Text", - "style": "text", - "id": "{{p}}", - "label": "{{p}}", - "isRequired": true, - "errorMessage": "{{errorMessage}}" -} -"""; - }).ToList(); - - var allInputs = string.Join(",", inputs); - - TemplateJson = $$""" -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.5", - "body": [ -""" + allInputs + $$""" - ], - "actions": [ - { - "type": "Action.Submit", - "title": "{{Resources.bookmarks_form_open}}", - "data": { - "placeholder": "placeholder" - } - } - ] -} -"""; - } - - public override CommandResult SubmitForm(string payload) - { - var target = _bookmark; - - // parse the submitted JSON and then open the link - var formInput = JsonNode.Parse(payload); - var formObject = formInput?.AsObject(); - if (formObject is null) - { - return CommandResult.GoHome(); - } - - foreach (var (key, value) in formObject) - { - var placeholderString = $"{{{key}}}"; - var placeholderData = value?.ToString(); - target = target.Replace(placeholderString, placeholderData); - } - - var success = UrlCommand.LaunchCommand(target); - - return success ? CommandResult.Dismiss() : CommandResult.KeepOpen(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs deleted file mode 100644 index 7cea160954..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class BookmarkPlaceholderPage : ContentPage -{ - private readonly Lazy _icon; - private readonly FormContent _bookmarkPlaceholder; - - public override IContent[] GetContent() => [_bookmarkPlaceholder]; - - public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; } - - public BookmarkPlaceholderPage(BookmarkData data) - : this(data.Name, data.Bookmark) - { - } - - public BookmarkPlaceholderPage(string name, string url) - { - Name = Properties.Resources.bookmarks_command_name_open; - - _bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url); - - _icon = new Lazy(() => - { - ShellHelpers.ParseExecutableAndArgs(url, out var exe, out var args); - var t = UrlCommand.GetIconForPath(exe); - t.Wait(); - return t.Result; - }); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs index 1174685729..df926129fb 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs @@ -2,186 +2,129 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Collections.Generic; +using System.Diagnostics.Contracts; using System.IO; using System.Linq; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CmdPal.Ext.Indexer; +using System.Threading; +using Microsoft.CmdPal.Ext.Bookmarks.Pages; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Bookmarks; -public partial class BookmarksCommandProvider : CommandProvider +public sealed partial class BookmarksCommandProvider : CommandProvider { - private readonly List _commands = []; + private const int LoadStateNotLoaded = 0; + private const int LoadStateLoading = 1; + private const int LoadStateLoaded = 2; - private readonly AddBookmarkPage _addNewCommand = new(null); + private readonly IPlaceholderParser _placeholderParser = new PlaceholderParser(); + private readonly IBookmarksManager _bookmarksManager; + private readonly IBookmarkResolver _commandResolver; + private readonly IBookmarkIconLocator _iconLocator = new IconLocator(); - private readonly IBookmarkDataSource _dataSource; - private readonly BookmarkJsonParser _parser; - private Bookmarks? _bookmarks; + private readonly ListItem _addNewItem; + private readonly Lock _bookmarksLock = new(); - public BookmarksCommandProvider() - : this(new FileBookmarkDataSource(StateJsonPath())) + private ICommandItem[] _commands = []; + private List _bookmarks = []; + private int _loadState; + + private static string StateJsonPath() { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, "bookmarks.json"); } - internal BookmarksCommandProvider(IBookmarkDataSource dataSource) + public static BookmarksCommandProvider CreateWithDefaultStore() { - _dataSource = dataSource; - _parser = new BookmarkJsonParser(); + return new BookmarksCommandProvider(new BookmarksManager(new FileBookmarkDataSource(StateJsonPath()))); + } + + internal BookmarksCommandProvider(IBookmarksManager bookmarksManager) + { + ArgumentNullException.ThrowIfNull(bookmarksManager); + _bookmarksManager = bookmarksManager; + _bookmarksManager.BookmarkAdded += OnBookmarkAdded; + _bookmarksManager.BookmarkRemoved += OnBookmarkRemoved; + + _commandResolver = new BookmarkResolver(_placeholderParser); Id = "Bookmarks"; DisplayName = Resources.bookmarks_display_name; Icon = Icons.PinIcon; - _addNewCommand.AddedCommand += AddNewCommand_AddedCommand; + var addBookmarkPage = new AddBookmarkPage(null); + addBookmarkPage.AddedCommand += (_, e) => _bookmarksManager.Add(e.Name, e.Bookmark); + _addNewItem = new ListItem(addBookmarkPage); } - private void AddNewCommand_AddedCommand(object sender, BookmarkData args) + private void OnBookmarkAdded(BookmarkData bookmarkData) { - ExtensionHost.LogMessage($"Adding bookmark ({args.Name},{args.Bookmark})"); - _bookmarks?.Data.Add(args); + var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser); + lock (_bookmarksLock) + { + _bookmarks.Add(newItem); + } - SaveAndUpdateCommands(); + NotifyChange(); } - // In the edit path, `args` was already in _bookmarks, we just updated it - private void Edit_AddedCommand(object sender, BookmarkData args) + private void OnBookmarkRemoved(BookmarkData bookmarkData) { - ExtensionHost.LogMessage($"Edited bookmark ({args.Name},{args.Bookmark})"); - - SaveAndUpdateCommands(); - } - - private void SaveAndUpdateCommands() - { - try + lock (_bookmarksLock) { - var jsonData = _parser.SerializeBookmarks(_bookmarks); - _dataSource.SaveBookmarkData(jsonData); - } - catch (Exception ex) - { - Logger.LogError($"Failed to save bookmarks: {ex.Message}"); + _bookmarks.RemoveAll(t => t.BookmarkId == bookmarkData.Id); } - LoadCommands(); - RaiseItemsChanged(0); - } - - private void LoadCommands() - { - List collected = []; - collected.Add(new CommandItem(_addNewCommand)); - - if (_bookmarks is null) - { - LoadBookmarksFromFile(); - } - - if (_bookmarks is not null) - { - collected.AddRange(_bookmarks.Data.Select(BookmarkToCommandItem)); - } - - _commands.Clear(); - _commands.AddRange(collected); - } - - private void LoadBookmarksFromFile() - { - try - { - var jsonData = _dataSource.GetBookmarkData(); - _bookmarks = _parser.ParseBookmarks(jsonData); - } - catch (Exception ex) - { - Logger.LogError(ex.Message); - } - - if (_bookmarks is null) - { - _bookmarks = new(); - } - } - - private CommandItem BookmarkToCommandItem(BookmarkData bookmark) - { - ICommand command = bookmark.IsPlaceholder ? - new BookmarkPlaceholderPage(bookmark) : - new UrlCommand(bookmark); - - var listItem = new CommandItem(command) { Icon = command.Icon }; - - List contextMenu = []; - - // Add commands for folder types - if (command is UrlCommand urlCommand) - { - if (!bookmark.IsWebUrl()) - { - contextMenu.Add( - new CommandContextItem(new DirectoryPage(urlCommand.Url))); - - contextMenu.Add( - new CommandContextItem(new OpenInTerminalCommand(urlCommand.Url))); - } - } - - listItem.Title = bookmark.Name; - listItem.Subtitle = bookmark.Bookmark; - - var edit = new AddBookmarkPage(bookmark) { Icon = Icons.EditIcon }; - edit.AddedCommand += Edit_AddedCommand; - contextMenu.Add(new CommandContextItem(edit)); - - var delete = new CommandContextItem( - title: Resources.bookmarks_delete_title, - name: Resources.bookmarks_delete_name, - action: () => - { - if (_bookmarks is not null) - { - ExtensionHost.LogMessage($"Deleting bookmark ({bookmark.Name},{bookmark.Bookmark})"); - - _bookmarks.Data.Remove(bookmark); - - SaveAndUpdateCommands(); - } - }, - result: CommandResult.KeepOpen()) - { - IsCritical = true, - Icon = Icons.DeleteIcon, - }; - contextMenu.Add(delete); - - listItem.MoreCommands = contextMenu.ToArray(); - - return listItem; + NotifyChange(); } public override ICommandItem[] TopLevelCommands() { - if (_commands.Count == 0) + if (Volatile.Read(ref _loadState) != LoadStateLoaded) { - LoadCommands(); + if (Interlocked.CompareExchange(ref _loadState, LoadStateLoading, LoadStateNotLoaded) == LoadStateNotLoaded) + { + try + { + lock (_bookmarksLock) + { + _bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser))]; + _commands = BuildTopLevelCommandsUnsafe(); + } + + Volatile.Write(ref _loadState, LoadStateLoaded); + RaiseItemsChanged(); + } + catch + { + Volatile.Write(ref _loadState, LoadStateNotLoaded); + throw; + } + } } - return _commands.ToArray(); + return _commands; } - internal static string StateJsonPath() + private void NotifyChange() { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); + if (Volatile.Read(ref _loadState) != LoadStateLoaded) + { + return; + } - // now, the state is just next to the exe - return System.IO.Path.Combine(directory, "bookmarks.json"); + lock (_bookmarksLock) + { + _commands = BuildTopLevelCommandsUnsafe(); + } + + RaiseItemsChanged(); } + + [Pure] + private ICommandItem[] BuildTopLevelCommandsUnsafe() => [_addNewItem, .. _bookmarks]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs new file mode 100644 index 0000000000..fde574360f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs @@ -0,0 +1,167 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal sealed partial class BookmarksManager : IDisposable, IBookmarksManager +{ + private readonly IBookmarkDataSource _dataSource; + private readonly BookmarkJsonParser _parser = new(); + private readonly SupersedingAsyncGate _savingGate; + private readonly Lock _lock = new(); + private BookmarksData _bookmarksData = new(); + + public event Action? BookmarkAdded; + + public event Action? BookmarkUpdated; // old, new + + public event Action? BookmarkRemoved; + + public IReadOnlyCollection Bookmarks + { + get + { + lock (_lock) + { + return _bookmarksData.Data.ToList().AsReadOnly(); + } + } + } + + public BookmarksManager(IBookmarkDataSource dataSource) + { + ArgumentNullException.ThrowIfNull(dataSource); + _dataSource = dataSource; + _savingGate = new SupersedingAsyncGate(WriteData); + LoadBookmarksFromFile(); + } + + public BookmarkData Add(string name, string bookmark) + { + var newBookmark = new BookmarkData(name, bookmark); + + lock (_lock) + { + _bookmarksData.Data.Add(newBookmark); + _ = SaveChangesAsync(); + BookmarkAdded?.Invoke(newBookmark); + return newBookmark; + } + } + + public bool Remove(Guid id) + { + lock (_lock) + { + var bookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id); + if (bookmark != null && _bookmarksData.Data.Remove(bookmark)) + { + _ = SaveChangesAsync(); + BookmarkRemoved?.Invoke(bookmark); + return true; + } + + return false; + } + } + + public BookmarkData? Update(Guid id, string name, string bookmark) + { + lock (_lock) + { + var existingBookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id); + if (existingBookmark != null) + { + var updatedBookmark = existingBookmark with + { + Name = name, + Bookmark = bookmark, + }; + + var index = _bookmarksData.Data.IndexOf(existingBookmark); + _bookmarksData.Data[index] = updatedBookmark; + + _ = SaveChangesAsync(); + BookmarkUpdated?.Invoke(existingBookmark, updatedBookmark); + return updatedBookmark; + } + + return null; + } + } + + private void LoadBookmarksFromFile() + { + try + { + var jsonData = _dataSource.GetBookmarkData(); + var bookmarksData = _parser.ParseBookmarks(jsonData); + + // Upgrade old bookmarks if necessary + // Pre .95 versions did not assign IDs to bookmarks + var upgraded = false; + for (var index = 0; index < bookmarksData.Data.Count; index++) + { + var bookmark = bookmarksData.Data[index]; + if (bookmark.Id == Guid.Empty) + { + bookmarksData.Data[index] = bookmark with { Id = Guid.NewGuid() }; + upgraded = true; + } + } + + lock (_lock) + { + _bookmarksData = bookmarksData; + } + + // LOAD BEARING: Save upgraded data back to file + // This ensures that old bookmarks are not repeatedly upgraded on each load, + // as the hotkeys and aliases are tied to the generated bookmark IDs. + if (upgraded) + { + _ = SaveChangesAsync(); + } + } + catch (Exception ex) + { + Logger.LogError(ex.Message); + } + } + + private Task WriteData(CancellationToken arg) + { + List dataToSave; + lock (_lock) + { + dataToSave = _bookmarksData.Data.ToList(); + } + + try + { + var jsonData = _parser.SerializeBookmarks(new BookmarksData { Data = dataToSave }); + _dataSource.SaveBookmarkData(jsonData); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save bookmarks: {ex.Message}"); + } + + return Task.CompletedTask; + } + + private async Task SaveChangesAsync() + { + await _savingGate.ExecuteAsync(CancellationToken.None); + } + + public void Dispose() => _savingGate.Dispose(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs new file mode 100644 index 0000000000..d6087b1481 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Commands; + +internal sealed partial class DeleteBookmarkCommand : InvokableCommand +{ + private readonly BookmarkData _bookmark; + private readonly IBookmarksManager _bookmarksManager; + + public DeleteBookmarkCommand(BookmarkData bookmark, IBookmarksManager bookmarksManager) + { + ArgumentNullException.ThrowIfNull(bookmark); + ArgumentNullException.ThrowIfNull(bookmarksManager); + + _bookmark = bookmark; + _bookmarksManager = bookmarksManager; + Name = Resources.bookmarks_delete_name; + Icon = Icons.DeleteIcon; + } + + public override CommandResult Invoke() + { + _bookmarksManager.Remove(_bookmark.Id); + return CommandResult.GoHome(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.cs new file mode 100644 index 0000000000..a5b3c460ba --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Text; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Commands; + +internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokableCommand, IDisposable +{ + private static readonly CompositeFormat FailedToOpenMessageFormat = CompositeFormat.Parse(Resources.bookmark_toast_failed_open_text!); + + private readonly BookmarkData _bookmarkData; + private readonly Dictionary? _placeholders; + private readonly IBookmarkResolver _bookmarkResolver; + private readonly SupersedingAsyncValueGate _iconReloadGate; + private readonly Classification _classification; + + private IIconInfo? _icon; + + public IIconInfo Icon => _icon ?? Icons.Reloading; + + public string Name { get; } + + public string Id { get; } + + public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, Dictionary? placeholders = null) + { + ArgumentNullException.ThrowIfNull(bookmarkData); + ArgumentNullException.ThrowIfNull(classification); + + _bookmarkData = bookmarkData; + _classification = classification; + _placeholders = placeholders; + _bookmarkResolver = bookmarkResolver; + + Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id); + Name = Resources.bookmarks_command_name_open; + + _iconReloadGate = new( + async ct => await iconLocator.GetIconForPath(_classification, ct), + icon => + { + _icon = icon; + OnPropertyChanged(nameof(Icon)); + }); + + RequestIconReloadAsync(); + } + + private void RequestIconReloadAsync() + { + _icon = null; + OnPropertyChanged(nameof(Icon)); + _ = _iconReloadGate.ExecuteAsync(); + } + + public ICommandResult Invoke(object sender) + { + var bookmarkAddress = ReplacePlaceholders(_bookmarkData.Bookmark); + var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress); + + var success = CommandLauncher.Launch(classification); + + return success + ? CommandResult.Dismiss() + : CommandResult.ShowToast(new ToastArgs + { + Message = !string.IsNullOrWhiteSpace(_bookmarkData.Name) + ? string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, _bookmarkData.Name + ": " + bookmarkAddress) + : string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, bookmarkAddress), + Result = CommandResult.KeepOpen(), + }); + } + + private string ReplacePlaceholders(string input) + { + var result = input; + if (_placeholders?.Count > 0) + { + foreach (var (key, value) in _placeholders) + { + var placeholderString = $"{{{key}}}"; + + var encodedValue = value; + if (_classification.Kind is CommandKind.Protocol or CommandKind.WebUrl) + { + encodedValue = Uri.EscapeDataString(value); + } + + result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase); + } + } + + return result; + } + + public void Dispose() + { + _iconReloadGate.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs new file mode 100644 index 0000000000..c391ea8586 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +global using System; +global using System.Collections.Generic; +global using Microsoft.CmdPal.Ext.Bookmarks.Properties; +global using Microsoft.CommandPalette.Extensions.Toolkit; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.cs new file mode 100644 index 0000000000..a9b1cb4837 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +public sealed record Classification( + CommandKind Kind, + string Input, + string Target, + string Arguments, + LaunchMethod Launch, + string? WorkingDirectory, + bool IsPlaceholder, + string? FileSystemTarget = null, + string? DisplayName = null) +{ + public static Classification Unknown(string rawInput) => + new(CommandKind.Unknown, rawInput, rawInput, string.Empty, LaunchMethod.ShellExecute, string.Empty, false, null, null); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs new file mode 100644 index 0000000000..57d82b6e30 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static class CommandIds +{ + /// + /// Returns id of a command associated with a bookmark item. This id is for a command that launches the bookmark - regardless of whether + /// the bookmark type of if it is a placeholder bookmark or not. + /// + /// Bookmark ID + public static string GetLaunchBookmarkItemId(Guid id) => "Bookmarks.Launch." + id; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs new file mode 100644 index 0000000000..9c9f0f053d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// +/// Classifies a command or bookmark target type. +/// +public enum CommandKind +{ + /// + /// Unknown or unsupported target. + /// + Unknown = 0, + + /// + /// HTTP/HTTPS URL. + /// + WebUrl, + + /// + /// Any non-file URI scheme (e.g., mailto:, ms-settings:, wt:, myapp:). + /// + Protocol, + + /// + /// Application User Model ID (e.g., shell:AppsFolder\AUMID or pkgfamily!app). + /// + Aumid, + + /// + /// Existing folder path. + /// + Directory, + + /// + /// Existing executable file (e.g., .exe, .bat, .cmd). + /// + FileExecutable, + + /// + /// Existing document file. + /// + FileDocument, + + /// + /// Windows shortcut file (*.lnk). + /// + Shortcut, + + /// + /// Internet shortcut file (*.url). + /// + InternetShortcut, + + /// + /// Bare command resolved via PATH/PATHEXT (e.g., "wt", "git"). + /// + PathCommand, + + /// + /// Shell item not matching other types (e.g., Control Panel item, purely virtual directory). + /// + VirtualShellItem, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs new file mode 100644 index 0000000000..742e272f4b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs @@ -0,0 +1,98 @@ +// 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.ComponentModel; +using System.Runtime.InteropServices; +using ManagedCommon; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static class CommandLauncher +{ + /// + /// Launches the classified item. + /// + /// Classification produced by CommandClassifier. + /// Optional: force elevation if possible. + public static bool Launch(Classification classification, bool runAsAdmin = false) + { + switch (classification.Launch) + { + case LaunchMethod.ExplorerOpen: + // Folders and shell: URIs are best handled by explorer.exe + // You can notice the difference with Recycle Bin for example: + // - "explorer ::{645FF040-5081-101B-9F08-00AA002F954E}" + // - "::{645FF040-5081-101B-9F08-00AA002F954E}" + return ShellHelpers.OpenInShell("explorer.exe", classification.Target); + + case LaunchMethod.ActivateAppId: + return ActivateAppId(classification.Target, classification.Arguments); + + case LaunchMethod.ShellExecute: + default: + return ShellHelpers.OpenInShell(classification.Target, classification.Arguments, classification.WorkingDirectory, runAsAdmin ? ShellHelpers.ShellRunAsType.Administrator : ShellHelpers.ShellRunAsType.None); + } + } + + private static bool ActivateAppId(string aumidOrAppsFolder, string? arguments) + { + const string shellAppsFolder = "shell:AppsFolder\\"; + try + { + if (aumidOrAppsFolder.StartsWith(shellAppsFolder, StringComparison.OrdinalIgnoreCase)) + { + aumidOrAppsFolder = aumidOrAppsFolder[shellAppsFolder.Length..]; + } + + ApplicationActivationManager.ActivateApplication(aumidOrAppsFolder, arguments, 0, out _); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Can't activate AUMID using app store '{aumidOrAppsFolder}'", ex); + } + + try + { + ShellHelpers.OpenInShell(shellAppsFolder + aumidOrAppsFolder, arguments); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Can't activate AUMID using shell '{aumidOrAppsFolder}'", ex); + } + + return false; + } + + private static class ApplicationActivationManager + { + public static void ActivateApplication(string aumid, string? args, int options, out uint pid) + { + var mgr = (IApplicationActivationManager)new _ApplicationActivationManager(); + var hr = mgr.ActivateApplication(aumid, args ?? string.Empty, options, out pid); + if (hr < 0) + { + throw new Win32Exception(hr); + } + } + + [ComImport] + [Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Private class")] + private class _ApplicationActivationManager; + + [ComImport] + [Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IApplicationActivationManager + { + int ActivateApplication( + [MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, + [MarshalAs(UnmanagedType.LPWStr)] string arguments, + int options, + out uint processId); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs new file mode 100644 index 0000000000..1d7cd1aca2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs @@ -0,0 +1,294 @@ +// 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.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// +/// Provides helper methods for parsing command lines and expanding paths. +/// +/// +/// Warning: This code handles parsing specifically for Bookmarks, and is NOT a general-purpose command line parser. +/// In some cases it mimics system rules (e.g. CreateProcess, CommandLineToArgvW) but in other cases it uses, but it can also +/// bend the rules to be more forgiving. +/// +internal static partial class CommandLineHelper +{ + private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + + public static string[] SplitCommandLine(string commandLine) + { + ArgumentNullException.ThrowIfNull(commandLine); + + var argv = NativeMethods.CommandLineToArgvW(commandLine, out var argc); + if (argv == IntPtr.Zero) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + var result = new string[argc]; + for (var i = 0; i < argc; i++) + { + var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size); + result[i] = Marshal.PtrToStringUni(p)!; + } + + return result; + } + finally + { + NativeMethods.LocalFree(argv); + } + } + + /// + /// Splits the raw command line into the first argument (Head) and the remainder (Tail). This method follows the rules + /// of CommandLineToArgvW. + /// + /// + /// This is a mental support for SplitLongestHeadBeforeQuotedArg. + /// + /// Rules: + /// - If the input starts with any whitespace, Head is an empty string (per CommandLineToArgvW behavior for first segment, handles by CreateProcess rules). + /// - Otherwise, Head uses the CreateProcess "program name" rule: + /// - If the first char is a quote, Head is everything up to the next quote (backslashes do NOT escape it). + /// - Else, Head is the run up to the first whitespace. + /// - Tail starts at the first non-whitespace character after Head (or is empty if nothing remains). + /// No normalization is performed; returned slices preserve the original text (no un/escaping). + /// + public static (string Head, string Tail) SplitHeadAndArgs(string input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.Length == 0) + { + return (string.Empty, string.Empty); + } + + var s = input.AsSpan(); + var n = s.Length; + var i = 0; + + // Leading whitespace -> empty argv[0] + if (char.IsWhiteSpace(s[0])) + { + while (i < n && char.IsWhiteSpace(s[i])) + { + i++; + } + + var tailAfterWs = i < n ? input[i..] : string.Empty; + return (string.Empty, tailAfterWs); + } + + string head; + if (s[i] == '"') + { + // Quoted program name: everything up to the next unescaped quote (CreateProcess rule: slashes don't escape here) + i++; + var start = i; + while (i < n && s[i] != '"') + { + i++; + } + + head = input.Substring(start, i - start); + if (i < n && s[i] == '"') + { + i++; // consume closing quote + } + } + else + { + // Unquoted program name: read to next whitespace + var start = i; + while (i < n && !char.IsWhiteSpace(s[i])) + { + i++; + } + + head = input.Substring(start, i - start); + } + + // Skip inter-argument whitespace; tail begins at the next non-ws char (or is empty) + while (i < n && char.IsWhiteSpace(s[i])) + { + i++; + } + + var tail = i < n ? input[i..] : string.Empty; + + return (head, tail); + } + + /// + /// Returns the longest possible head (may include spaces) and the tail that starts at the + /// first *quoted argument*. + /// + /// Definition of "quoted argument start": + /// - A token boundary (start-of-line or preceded by whitespace), + /// - followed by zero or more backslashes, + /// - followed by a double-quote ("), + /// - where the number of immediately preceding backslashes is EVEN (so the quote toggles quoting). + /// + /// Notes: + /// - Quotes appearing mid-token (e.g., C:\Some\"Path\file.txt) do NOT stop the head. + /// - Trailing spaces before the quoted arg are not included in Head; Tail begins at that quote. + /// - Leading whitespace before the first token is ignored (Head starts from first non-ws). + /// Examples: + /// C:\app exe -p "1" -q -> Head: "C:\app exe -p", Tail: "\"1\" -q" + /// "\\server\share\" with args -> Head: "", Tail: "\"\\\\server\\share\\\" with args" + /// C:\Some\"Path\file.txt -> Head: "C:\\Some\\\"Path\\file.txt", Tail: "" + /// + public static (string Head, string Tail) SplitLongestHeadBeforeQuotedArg(string input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.Length == 0) + { + return (string.Empty, string.Empty); + } + + var s = input.AsSpan(); + var n = s.Length; + + // Start at first non-whitespace (we don't treat leading ws as part of Head here) + var start = 0; + while (start < n && char.IsWhiteSpace(s[start])) + { + start++; + } + + if (start >= n) + { + return (string.Empty, string.Empty); + } + + // Scan for a quote that OPENS a quoted argument at a token boundary. + for (var i = start; i < n; i++) + { + if (s[i] != '"') + { + continue; + } + + // Count immediate backslashes before this quote + int j = i - 1, backslashes = 0; + while (j >= start && s[j] == '\\') + { + backslashes++; + j--; + } + + // The quote is at a token boundary if the char before the backslashes is start-of-line or whitespace. + var atTokenBoundary = j < start || char.IsWhiteSpace(s[j]); + + // Even number of backslashes -> this quote toggles quoting (opens if at boundary). + if (atTokenBoundary && (backslashes % 2 == 0)) + { + // Trim trailing spaces off Head so Tail starts exactly at the opening quote + var headEnd = i; + while (headEnd > start && char.IsWhiteSpace(s[headEnd - 1])) + { + headEnd--; + } + + var head = input[start..headEnd]; + var tail = input[headEnd..]; // starts at the opening quote + return (head, tail.Trim()); + } + } + + // No quoted-arg start found: entire remainder (trimmed right) is the Head + var wholeHead = input[start..].TrimEnd(); + return (wholeHead, string.Empty); + } + + /// + /// Attempts to expand the path to full physical path, expanding environment variables and shell: monikers. + /// + internal static bool ExpandPathToPhysicalFile(string input, bool expandShell, out string full) + { + if (string.IsNullOrEmpty(input)) + { + full = string.Empty; + return false; + } + + var expanded = Environment.ExpandEnvironmentVariables(input); + + var firstSegment = GetFirstPathSegment(expanded); + if (expandShell && HasShellPrefix(firstSegment) && TryExpandShellMoniker(expanded, out var shellExpanded)) + { + expanded = shellExpanded; + } + else if (firstSegment is "~" or "." or "..") + { + expanded = ExpandUserRelative(firstSegment, expanded); + } + + if (Path.Exists(expanded)) + { + full = Path.GetFullPath(expanded); + return true; + } + + full = expanded; // return the attempted expansion even if it doesn't exist + return false; + } + + private static bool TryExpandShellMoniker(string input, out string expanded) + { + var separatorIndex = input.IndexOfAny(PathSeparators); + var shellFolder = separatorIndex > 0 ? input[..separatorIndex] : input; + var relativePath = separatorIndex > 0 ? input[(separatorIndex + 1)..] : string.Empty; + + if (ShellNames.TryGetFileSystemPath(shellFolder, out var fsPath)) + { + expanded = Path.GetFullPath(Path.Combine(fsPath, relativePath)); + return true; + } + + expanded = input; + return false; + } + + private static string ExpandUserRelative(string firstSegment, string input) + { + // Treat relative paths as relative to the user home directory. + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (firstSegment == "~") + { + // Remove "~" (+ optional following separator) before combining. + var skip = 1; + if (input.Length > 1 && IsSeparator(input[1])) + { + skip++; + } + + input = input[skip..]; + } + + return Path.GetFullPath(Path.Combine(homeDirectory, input)); + } + + private static bool IsSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + + private static string GetFirstPathSegment(string input) + { + var separatorIndex = input.IndexOfAny(PathSeparators); + return separatorIndex > 0 ? input[..separatorIndex] : input; + } + + internal static bool HasShellPrefix(string input) + { + return input.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || input.StartsWith("::", StringComparison.Ordinal); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.cs new file mode 100644 index 0000000000..eaedb88aea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +public enum LaunchMethod +{ + ShellExecute, // UseShellExecute = true (Explorer/associations/protocols) + ExplorerOpen, // explorer.exe + ActivateAppId, // IApplicationActivationManager (AUMID / pkgfamily!app) +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs new file mode 100644 index 0000000000..9cba2aba74 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs @@ -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.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static partial class NativeMethods +{ + [LibraryImport("shell32.dll", EntryPoint = "SHParseDisplayName", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int SHParseDisplayName( + string pszName, + nint pbc, + out nint ppidl, + uint sfgaoIn, + nint psfgaoOut); + + [LibraryImport("shell32.dll", EntryPoint = "SHGetNameFromIDList", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int SHGetNameFromIDList( + nint pidl, + SIGDN sigdnName, + out nint ppszName); + + [LibraryImport("ole32.dll")] + internal static partial void CoTaskMemFree(nint pv); + + [LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs); + + [LibraryImport("kernel32.dll")] + internal static partial IntPtr LocalFree(IntPtr hMem); + + internal enum SIGDN : uint + { + NORMALDISPLAY = 0x00000000, + DESKTOPABSOLUTEPARSING = 0x80028000, + DESKTOPABSOLUTEEDITING = 0x8004C000, + FILESYSPATH = 0x80058000, + URL = 0x80068000, + PARENTRELATIVE = 0x80080001, + PARENTRELATIVEFORADDRESSBAR = 0x8007C001, + PARENTRELATIVEPARSING = 0x80018001, + PARENTRELATIVEEDITING = 0x80031001, + PARENTRELATIVEFORUI = 0x80094001, + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.cs new file mode 100644 index 0000000000..d290deff47 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// +/// Helpers for getting user-friendly shell names and paths. +/// +internal static class ShellNames +{ + /// + /// Tries to get a localized friendly name (e.g. "This PC", "Downloads") for a shell path like: + /// - "shell:Downloads" + /// - "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}" + /// - "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}" + /// + public static bool TryGetFriendlyName(string shellPath, [NotNullWhen(true)] out string? displayName) + { + displayName = null; + + // Normalize a bare GUID to the "::" moniker if someone passes only "{GUID}" + if (shellPath.Length > 0 && shellPath[0] == '{' && shellPath[^1] == '}') + { + shellPath = "::" + shellPath; + } + + nint pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0); + if (hr != 0 || pidl == 0) + { + return false; + } + + // Ask for the human-friendly localized name + nint psz; + hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.NORMALDISPLAY, out psz); + if (hr != 0 || psz == 0) + { + return false; + } + + try + { + displayName = Marshal.PtrToStringUni(psz); + return !string.IsNullOrWhiteSpace(displayName); + } + finally + { + NativeMethods.CoTaskMemFree(psz); + } + } + finally + { + if (pidl != 0) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } + + /// + /// Optionally, also try to obtain a filesystem path (if the item represents one). + /// Returns false for purely virtual items like "This PC". + /// + public static bool TryGetFileSystemPath(string shellPath, [NotNullWhen(true)] out string? fileSystemPath) + { + fileSystemPath = null; + + nint pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0); + if (hr != 0 || pidl == 0) + { + return false; + } + + nint psz; + hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.FILESYSPATH, out psz); + if (hr != 0 || psz == 0) + { + return false; + } + + try + { + fileSystemPath = Marshal.PtrToStringUni(psz); + return !string.IsNullOrWhiteSpace(fileSystemPath); + } + finally + { + NativeMethods.CoTaskMemFree(psz); + } + } + finally + { + if (pidl != 0) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs new file mode 100644 index 0000000000..14befe9a68 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static class UriHelper +{ + /// + /// Tries to split a URI string into scheme and remainder. + /// Scheme must be valid per RFC 3986 and followed by ':'. + /// + public static bool TryGetScheme(ReadOnlySpan input, out string scheme, out string remainder) + { + // https://datatracker.ietf.org/doc/html/rfc3986#page-17 + scheme = string.Empty; + remainder = string.Empty; + + if (input.Length < 2) + { + return false; // must have at least "a:" + } + + // Must contain ':' delimiter + var colonIndex = input.IndexOf(':'); + if (colonIndex <= 0) + { + return false; // no colon or colon at start + } + + // First char must be a letter + var first = input[0]; + if (!char.IsLetter(first)) + { + return false; + } + + // Validate scheme part + for (var i = 1; i < colonIndex; i++) + { + var c = input[i]; + if (!(char.IsLetterOrDigit(c) || c == '+' || c == '-' || c == '.')) + { + return false; + } + } + + // Extract scheme and remainder + scheme = input[..colonIndex].ToString(); + remainder = colonIndex + 1 < input.Length ? input[(colonIndex + 1)..].ToString() : string.Empty; + return true; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.cs new file mode 100644 index 0000000000..74ab025d0f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal interface IBookmarksManager +{ + event Action? BookmarkAdded; + + event Action? BookmarkUpdated; + + event Action? BookmarkRemoved; + + IReadOnlyCollection Bookmarks { get; } + + BookmarkData Add(string name, string bookmark); + + bool Remove(Guid id); + + BookmarkData? Update(Guid id, string name, string bookmark); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs index 6f8fd8b05e..6e7d955606 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs @@ -2,17 +2,41 @@ // 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.CommandPalette.Extensions.Toolkit; - namespace Microsoft.CmdPal.Ext.Bookmarks; -internal sealed class Icons +internal static class Icons { - internal static IconInfo BookmarkIcon => IconHelpers.FromRelativePath("Assets\\Bookmark.svg"); + internal static IconInfo BookmarkIcon { get; } = IconHelpers.FromRelativePath("Assets\\Bookmark.svg"); - internal static IconInfo DeleteIcon { get; private set; } = new("\uE74D"); // Delete + internal static IconInfo DeleteIcon { get; } = new("\uE74D"); // Delete - internal static IconInfo EditIcon { get; private set; } = new("\uE70F"); // Edit + internal static IconInfo EditIcon { get; } = new("\uE70F"); // Edit - internal static IconInfo PinIcon { get; private set; } = new IconInfo("\uE718"); // Pin + internal static IconInfo PinIcon { get; } = new IconInfo("\uE718"); // Pin + + internal static IconInfo Reloading { get; } = new IconInfo("\uF16A"); // ProgressRing + + internal static IconInfo CopyPath { get; } = new IconInfo("\uE8C8"); // Copy + + internal static class BookmarkTypes + { + internal static IconInfo WebUrl { get; } = new("\uE774"); // Globe + + internal static IconInfo FilePath { get; } = new("\uE8A5"); // OpenFile + + internal static IconInfo FolderPath { get; } = new("\uE8B7"); // OpenFolder + + internal static IconInfo Application { get; } = new("\uE737"); // Favicon (~looks like empty window) + + internal static IconInfo Command { get; } = new("\uE756"); // CommandPrompt + + internal static IconInfo Unknown { get; } = new("\uE71B"); // Link + + internal static IconInfo Game { get; } = new("\uE7FC"); // Game controller + } + + private static IconInfo DualColorFromRelativePath(string name) + { + return IconHelpers.FromRelativePaths($"Assets\\Icons\\{name}.light.svg", $"Assets\\Icons\\{name}.dark.svg"); + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.cs new file mode 100644 index 0000000000..18d818b727 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CommandPalette.Extensions; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal static class KeyChords +{ + internal static KeyChord CopyPath => WellKnownKeyChords.CopyFilePath; + + internal static KeyChord OpenFileLocation => WellKnownKeyChords.OpenFileLocation; + + internal static KeyChord OpenInConsole => WellKnownKeyChords.OpenInConsole; + + internal static KeyChord DeleteBookmark => KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj index 40c3cca9f2..f47b5e216f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj @@ -10,13 +10,15 @@ Microsoft.CmdPal.Ext.Bookmarks.pri - - - + + + PreserveNewest + + @@ -26,14 +28,6 @@ - - - PreserveNewest - - - PreserveNewest - - Resources.Designer.cs @@ -41,4 +35,7 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs deleted file mode 100644 index 1ea5016cf4..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class OpenInTerminalCommand : InvokableCommand -{ - private readonly string _folder; - - public OpenInTerminalCommand(string folder) - { - Name = Resources.bookmarks_open_in_terminal_name; - _folder = folder; - } - - public override ICommandResult Invoke() - { - try - { - // Start Windows Terminal with the specified folder - var startInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "wt.exe", - Arguments = $"-d \"{_folder}\"", - UseShellExecute = true, - }; - System.Diagnostics.Process.Start(startInfo); - } - catch (Exception ex) - { - Logger.LogError(ex.Message); - } - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs new file mode 100644 index 0000000000..e165bfd0d4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class AddBookmarkForm : FormContent +{ + private readonly BookmarkData? _bookmark; + + internal event TypedEventHandler? AddedCommand; + + public AddBookmarkForm(BookmarkData? bookmark) + { + _bookmark = bookmark; + var name = bookmark?.Name ?? string.Empty; + var url = bookmark?.Bookmark ?? string.Empty; + TemplateJson = $$""" +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "Input.Text", + "style": "text", + "id": "bookmark", + "value": {{EncodeString(url)}}, + "label": {{EncodeString(Resources.bookmarks_form_bookmark_label)}}, + "isRequired": true, + "errorMessage": {{EncodeString(Resources.bookmarks_form_bookmark_required)}}, + "placeholder": {{EncodeString(Resources.bookmarks_form_bookmark_placeholder)}} + }, + { + "type": "Input.Text", + "style": "text", + "id": "name", + "label": {{EncodeString(Resources.bookmarks_form_name_label)}}, + "value": {{EncodeString(name)}}, + "isRequired": false + }, + { + "type": "RichTextBlock", + "inlines": [ + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text1)}}, + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text2)}}, + "fontType": "Monospace", + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text3)}}, + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text4)}}, + "fontType": "Monospace", + "size": "Small" + } + ] + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": {{EncodeString(Resources.bookmarks_form_save)}}, + "data": { + "name": "name", + "bookmark": "bookmark" + } + } + ] +} +"""; + } + + private static string EncodeString(string s) => JsonSerializer.Serialize(s, BookmarkSerializationContext.Default.String); + + public override CommandResult SubmitForm(string payload) + { + var formInput = JsonNode.Parse(payload); + if (formInput is null) + { + return CommandResult.GoHome(); + } + + // get the name and url out of the values + var formName = formInput["name"] ?? string.Empty; + var formBookmark = formInput["bookmark"] ?? string.Empty; + AddedCommand?.Invoke(this, new BookmarkData(formName.ToString(), formBookmark.ToString()) { Id = _bookmark?.Id ?? Guid.Empty }); + return CommandResult.GoHome(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs similarity index 68% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs index d74b942990..927044e77c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs @@ -2,33 +2,33 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CmdPal.Ext.Bookmarks.Properties; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; internal sealed partial class AddBookmarkPage : ContentPage { - private readonly AddBookmarkForm _addBookmark; - internal event TypedEventHandler? AddedCommand { - add => _addBookmark.AddedCommand += value; - remove => _addBookmark.AddedCommand -= value; + add => _addBookmarkForm.AddedCommand += value; + remove => _addBookmarkForm.AddedCommand -= value; } - public override IContent[] GetContent() => [_addBookmark]; + private readonly AddBookmarkForm _addBookmarkForm; public AddBookmarkPage(BookmarkData? bookmark) { var name = bookmark?.Name ?? string.Empty; var url = bookmark?.Bookmark ?? string.Empty; + Icon = Icons.BookmarkIcon; var isAdd = string.IsNullOrEmpty(name) && string.IsNullOrEmpty(url); Title = isAdd ? Resources.bookmarks_add_title : Resources.bookmarks_edit_name; Name = isAdd ? Resources.bookmarks_add_name : Resources.bookmarks_edit_name; - _addBookmark = new(bookmark); + _addBookmarkForm = new AddBookmarkForm(bookmark); } + + public override IContent[] GetContent() => [_addBookmarkForm]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs new file mode 100644 index 0000000000..fe1e56c66e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Common.Commands; +using Microsoft.CmdPal.Core.Common.Commands; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Commands; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CmdPal.Ext.Indexer; +using Microsoft.CommandPalette.Extensions; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkListItem : ListItem, IDisposable +{ + private readonly IBookmarksManager _bookmarksManager; + private readonly IBookmarkResolver _commandResolver; + private readonly IBookmarkIconLocator _iconLocator; + private readonly IPlaceholderParser _placeholderParser; + private readonly SupersedingAsyncValueGate _classificationGate; + private readonly TaskCompletionSource _initializationTcs = new(); + + private BookmarkData _bookmark; + + public Task IsInitialized => _initializationTcs.Task; + + public string BookmarkAddress => _bookmark.Bookmark; + + public string BookmarkTitle => _bookmark.Name; + + public Guid BookmarkId => _bookmark.Id; + + public BookmarkListItem(BookmarkData bookmark, IBookmarksManager bookmarksManager, IBookmarkResolver commandResolver, IBookmarkIconLocator iconLocator, IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(bookmark); + ArgumentNullException.ThrowIfNull(bookmarksManager); + ArgumentNullException.ThrowIfNull(commandResolver); + + _bookmark = bookmark; + _bookmarksManager = bookmarksManager; + _bookmarksManager.BookmarkUpdated += BookmarksManagerOnBookmarkUpdated; + _commandResolver = commandResolver; + _iconLocator = iconLocator; + _placeholderParser = placeholderParser; + _classificationGate = new SupersedingAsyncValueGate(ClassifyAsync, ApplyClassificationResult); + _ = _classificationGate.ExecuteAsync(); + } + + private void BookmarksManagerOnBookmarkUpdated(BookmarkData original, BookmarkData @new) + { + if (original.Id == _bookmark.Id) + { + Update(@new); + } + } + + public void Dispose() + { + _classificationGate.Dispose(); + var existing = Command; + if (existing != null) + { + existing.PropChanged -= CommandPropertyChanged; + } + } + + private void Update(BookmarkData data) + { + ArgumentNullException.ThrowIfNull(data); + + try + { + _bookmark = data; + OnPropertyChanged(nameof(BookmarkTitle)); + OnPropertyChanged(nameof(BookmarkAddress)); + + Subtitle = Resources.bookmarks_item_refreshing; + _ = _classificationGate.ExecuteAsync(); + } + catch (Exception ex) + { + Logger.LogError("Failed to update bookmark", ex); + } + } + + private async Task ClassifyAsync(CancellationToken ct) + { + TypedEventHandler bookmarkSavedHandler = BookmarkSaved; + List contextMenu = []; + + var classification = (await _commandResolver.TryClassifyAsync(_bookmark.Bookmark, ct)).Result; + + var title = BuildTitle(_bookmark, classification); + var subtitle = BuildSubtitle(_bookmark, classification); + + ICommand command = classification.IsPlaceholder + ? new BookmarkPlaceholderPage(_bookmark, _iconLocator, _commandResolver, _placeholderParser) + : new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver); + + BuildSpecificContextMenuItems(classification, contextMenu); + AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu); + + return new BookmarkListItemReclassifyResult( + command, + title, + subtitle, + contextMenu.ToArray()); + } + + private void ApplyClassificationResult(BookmarkListItemReclassifyResult classificationResult) + { + var existing = Command; + if (existing != null) + { + existing.PropChanged -= CommandPropertyChanged; + } + + classificationResult.Command.PropChanged += CommandPropertyChanged; + Command = classificationResult.Command; + OnPropertyChanged(nameof(Icon)); + Title = classificationResult.Title; + Subtitle = classificationResult.Subtitle; + MoreCommands = classificationResult.MoreCommands; + + _initializationTcs.TrySetResult(); + } + + private void CommandPropertyChanged(object sender, IPropChangedEventArgs args) => + OnPropertyChanged(args.PropertyName); + + private static void BuildSpecificContextMenuItems(Classification classification, List contextMenu) + { + // TODO: unify across all built-in extensions + var bookmarkTargetType = classification.Kind; + + // TODO: add "Run as administrator" for executables/shortcuts + if (!classification.IsPlaceholder) + { + if (bookmarkTargetType == CommandKind.FileDocument && File.Exists(classification.Target)) + { + contextMenu.Add(new CommandContextItem(new OpenWithCommand(classification.Input))); + } + } + + string? directoryPath = null; + var targetPath = classification.Target; + switch (bookmarkTargetType) + { + case CommandKind.Directory: + directoryPath = targetPath; + contextMenu.Add(new CommandContextItem(new DirectoryPage(directoryPath))); // Browse + break; + case CommandKind.FileExecutable: + case CommandKind.FileDocument: + case CommandKind.Shortcut: + case CommandKind.InternetShortcut: + try + { + directoryPath = Path.GetDirectoryName(targetPath); + } + catch + { + // ignore any path parsing errors + } + + break; + case CommandKind.WebUrl: + case CommandKind.Protocol: + case CommandKind.Aumid: + case CommandKind.PathCommand: + case CommandKind.Unknown: + default: + break; + } + + // Add "Copy Path" or "Copy Address" command + if (!string.IsNullOrWhiteSpace(classification.Input)) + { + var copyCommand = new CopyPathCommand(targetPath) + { + Name = bookmarkTargetType is CommandKind.WebUrl or CommandKind.Protocol + ? Resources.bookmarks_copy_address_name + : Resources.bookmarks_copy_path_name, + Icon = Icons.CopyPath, + }; + + contextMenu.Add(new CommandContextItem(copyCommand) { RequestedShortcut = KeyChords.CopyPath }); + } + + // Add "Open in Console" and "Show in Folder" commands if we have a valid directory path + if (!string.IsNullOrWhiteSpace(directoryPath) && Directory.Exists(directoryPath)) + { + contextMenu.Add(new CommandContextItem(new ShowFileInFolderCommand(targetPath)) { RequestedShortcut = KeyChords.OpenFileLocation }); + contextMenu.Add(new CommandContextItem(OpenInConsoleCommand.FromDirectory(directoryPath)) { RequestedShortcut = KeyChords.OpenInConsole }); + } + + if (!string.IsNullOrWhiteSpace(targetPath) && (File.Exists(targetPath) || Directory.Exists(targetPath))) + { + contextMenu.Add(new CommandContextItem(new OpenPropertiesCommand(targetPath))); + } + } + + private static string BuildSubtitle(BookmarkData bookmark, Classification classification) + { + var subtitle = BuildSubtitleCore(bookmark, classification); +#if DEBUG + subtitle = $" ({classification.Kind}) • " + subtitle; +#endif + return subtitle; + } + + private static string BuildSubtitleCore(BookmarkData bookmark, Classification classification) + { + if (classification.Kind == CommandKind.Unknown) + { + return bookmark.Bookmark; + } + + if (classification.Kind is CommandKind.VirtualShellItem && + ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName)) + { + return friendlyName; + } + + if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) && + !string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + + return bookmark.Bookmark; + } + + private static string BuildTitle(BookmarkData bookmark, Classification classification) + { + if (!string.IsNullOrWhiteSpace(bookmark.Name)) + { + return bookmark.Name; + } + + if (classification.Kind is CommandKind.Unknown or CommandKind.WebUrl or CommandKind.Protocol) + { + return bookmark.Bookmark; + } + + if (ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName)) + { + return friendlyName; + } + + if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) && + !string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + + return bookmark.Bookmark; + } + + private static void AddCommonContextMenuItems( + BookmarkData bookmark, + IBookmarksManager bookmarksManager, + TypedEventHandler bookmarkSavedHandler, + List contextMenu) + { + contextMenu.Add(new Separator()); + + var edit = new AddBookmarkPage(bookmark) { Icon = Icons.EditIcon }; + edit.AddedCommand += bookmarkSavedHandler; + contextMenu.Add(new CommandContextItem(edit)); + + var confirmableCommand = new ConfirmableCommand + { + Command = new DeleteBookmarkCommand(bookmark, bookmarksManager), + ConfirmationTitle = Resources.bookmarks_delete_prompt_title!, + ConfirmationMessage = Resources.bookmarks_delete_prompt_message!, + Name = Resources.bookmarks_delete_name, + Icon = Icons.DeleteIcon, + }; + var delete = new CommandContextItem(confirmableCommand) { IsCritical = true, RequestedShortcut = KeyChords.DeleteBookmark }; + contextMenu.Add(delete); + } + + private void BookmarkSaved(object sender, BookmarkData args) + { + ExtensionHost.LogMessage($"Saving bookmark ({args.Name},{args.Bookmark})"); + _bookmarksManager.Update(args.Id, args.Name, args.Bookmark); + } + + private readonly record struct BookmarkListItemReclassifyResult( + ICommand Command, + string Title, + string Subtitle, + IContextItem[] MoreCommands + ); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs new file mode 100644 index 0000000000..8064474fab --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkPlaceholderForm : FormContent +{ + private static readonly CompositeFormat ErrorMessage = CompositeFormat.Parse(Resources.bookmarks_required_placeholder); + + private readonly BookmarkData _bookmarkData; + private readonly IBookmarkResolver _commandResolver; + + public BookmarkPlaceholderForm(BookmarkData data, IBookmarkResolver commandResolver, IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(commandResolver); + + _bookmarkData = data; + _commandResolver = commandResolver; + placeholderParser.ParsePlaceholders(data.Bookmark, out _, out var placeholders); + var inputs = placeholders.Distinct(PlaceholderInfoNameEqualityComparer.Instance).Select(placeholder => + { + var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, placeholder.Name); + return $$""" + { + "type": "Input.Text", + "style": "text", + "id": "{{placeholder.Name}}", + "label": "{{placeholder.Name}}", + "isRequired": true, + "errorMessage": "{{errorMessage}}" + } + """; + }).ToList(); + + var allInputs = string.Join(",", inputs); + + TemplateJson = $$""" + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": "{{_bookmarkData.Name}}" + }, + {{allInputs}} + ], + "actions": [ + { + "type": "Action.Submit", + "title": "{{Resources.bookmarks_form_open}}", + "data": { + "placeholder": "placeholder" + } + } + ] + } + """; + } + + public override CommandResult SubmitForm(string payload) + { + // parse the submitted JSON and then open the link + var formInput = JsonNode.Parse(payload); + var formObject = formInput?.AsObject(); + if (formObject is null) + { + return CommandResult.GoHome(); + } + + // we need to classify this twice: + // first we need to know if the original bookmark is a URL or protocol, because that determines how we encode the placeholders + // then we need to classify the final target to be sure the classification didn't change by adding the placeholders + var placeholderClassification = _commandResolver.ClassifyOrUnknown(_bookmarkData.Bookmark); + + var placeholders = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in formObject) + { + var placeholderData = value?.ToString(); + placeholders[key] = placeholderData ?? string.Empty; + } + + var target = ReplacePlaceholders(_bookmarkData.Bookmark, placeholders, placeholderClassification); + var classification = _commandResolver.ClassifyOrUnknown(target); + var success = CommandLauncher.Launch(classification); + return success ? CommandResult.Dismiss() : CommandResult.KeepOpen(); + } + + private static string ReplacePlaceholders(string input, Dictionary placeholders, Classification classification) + { + var result = input; + foreach (var (key, value) in placeholders) + { + var placeholderString = $"{{{key}}}"; + var encodedValue = value; + if (classification.Kind is CommandKind.Protocol or CommandKind.WebUrl) + { + encodedValue = Uri.EscapeDataString(value); + } + + result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase); + } + + return result; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs new file mode 100644 index 0000000000..06b23c5252 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable +{ + private readonly FormContent _bookmarkPlaceholder; + private readonly SupersedingAsyncValueGate _iconReloadGate; + + public BookmarkPlaceholderPage(BookmarkData bookmarkData, IBookmarkIconLocator iconLocator, IBookmarkResolver resolver, IPlaceholderParser placeholderParser) + { + Name = Resources.bookmarks_command_name_open; + Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id); + + _bookmarkPlaceholder = new BookmarkPlaceholderForm(bookmarkData, resolver, placeholderParser); + + _iconReloadGate = new( + async ct => + { + var c = resolver.ClassifyOrUnknown(bookmarkData.Bookmark); + return await iconLocator.GetIconForPath(c, ct); + }, + icon => + { + Icon = icon as IconInfo ?? Icons.PinIcon; + }); + RequestIconReloadAsync(); + } + + public override IContent[] GetContent() => [_bookmarkPlaceholder]; + + private void RequestIconReloadAsync() + { + Icon = Icons.Reloading; + OnPropertyChanged(nameof(Icon)); + _ = _iconReloadGate.ExecuteAsync(); + } + + public void Dispose() => _iconReloadGate.Dispose(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs new file mode 100644 index 0000000000..b577f9cb35 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +public sealed record BookmarkData +{ + public Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Bookmark { get; init; } + + [JsonConstructor] + [SetsRequiredMembers] + public BookmarkData(Guid id, string? name, string? bookmark) + { + Id = id; + Name = name ?? string.Empty; + Bookmark = bookmark ?? string.Empty; + } + + [SetsRequiredMembers] + public BookmarkData(string? name, string? bookmark) + : this(Guid.NewGuid(), name, bookmark) + { + } + + [SetsRequiredMembers] + public BookmarkData() + : this(Guid.NewGuid(), string.Empty, string.Empty) + { + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs similarity index 63% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs index 7cc82c9c02..c0eb26b7b7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs @@ -2,11 +2,9 @@ // 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 System.Text.Json; -using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; public class BookmarkJsonParser { @@ -14,32 +12,32 @@ public class BookmarkJsonParser { } - public Bookmarks ParseBookmarks(string json) + public BookmarksData ParseBookmarks(string json) { if (string.IsNullOrWhiteSpace(json)) { - return new Bookmarks(); + return new BookmarksData(); } try { - var bookmarks = JsonSerializer.Deserialize(json, BookmarkSerializationContext.Default.Bookmarks); - return bookmarks ?? new Bookmarks(); + var bookmarks = JsonSerializer.Deserialize(json, BookmarkSerializationContext.Default.BookmarksData); + return bookmarks ?? new BookmarksData(); } catch (JsonException ex) { ExtensionHost.LogMessage($"parse bookmark data failed. ex: {ex.Message}"); - return new Bookmarks(); + return new BookmarksData(); } } - public string SerializeBookmarks(Bookmarks? bookmarks) + public string SerializeBookmarks(BookmarksData? bookmarks) { if (bookmarks == null) { return string.Empty; } - return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.Bookmarks); + return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.BookmarksData); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs similarity index 84% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs index 9730bf214d..66c5c69455 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs @@ -2,19 +2,16 @@ // 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 System.Text.Json.Serialization; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; [JsonSerializable(typeof(float))] [JsonSerializable(typeof(int))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(BookmarkData))] -[JsonSerializable(typeof(Bookmarks))] +[JsonSerializable(typeof(BookmarksData))] [JsonSerializable(typeof(List), TypeInfoPropertyName = "BookmarkList")] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] -internal sealed partial class BookmarkSerializationContext : JsonSerializerContext -{ -} +internal sealed partial class BookmarkSerializationContext : JsonSerializerContext; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.cs similarity index 62% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.cs index b02eb54e0f..81d0f21578 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.cs @@ -2,13 +2,9 @@ // 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 System.IO; -using System.Text.Json; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; -namespace Microsoft.CmdPal.Ext.Bookmarks; - -public sealed class Bookmarks +public sealed class BookmarksData { public List Data { get; set; } = []; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs similarity index 85% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs index a87859c3ce..69dd934e2c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs @@ -2,13 +2,11 @@ // 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.IO; -using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; -public class FileBookmarkDataSource : IBookmarkDataSource +public sealed partial class FileBookmarkDataSource : IBookmarkDataSource { private readonly string _filePath; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs similarity index 73% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs index 7ed936a1c7..890d3683ba 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; -public interface IBookmarkDataSource +internal interface IBookmarkDataSource { string GetBookmarkData(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs index 9cdf20805d..02f95cf479 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to Failed to open {0}. + /// + public static string bookmark_toast_failed_open_text { + get { + return ResourceManager.GetString("bookmark_toast_failed_open_text", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add bookmark. /// @@ -87,6 +96,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to Copy address. + /// + public static string bookmarks_copy_address_name { + get { + return ResourceManager.GetString("bookmarks_copy_address_name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copy path. + /// + public static string bookmarks_copy_path_name { + get { + return ResourceManager.GetString("bookmarks_copy_path_name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete. /// @@ -96,6 +123,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to Are you sure you want to delete this bookmark?. + /// + public static string bookmarks_delete_prompt_message { + get { + return ResourceManager.GetString("bookmarks_delete_prompt_message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete bookmark?. + /// + public static string bookmarks_delete_prompt_title { + get { + return ResourceManager.GetString("bookmarks_delete_prompt_title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete bookmark. /// @@ -132,6 +177,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to Enter URL or file path, you can use {placeholders}, e.g. https://www.bing.com/search?q={Query}. + /// + public static string bookmarks_form_bookmark_placeholder { + get { + return ResourceManager.GetString("bookmarks_form_bookmark_placeholder", resourceCulture); + } + } + /// /// Looks up a localized string similar to URL or file path is required. /// @@ -142,7 +196,44 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } /// - /// Looks up a localized string similar to Name. + /// Looks up a localized string similar to You can add placeholders to bookmarks, and Command Palette will prompt you to enter their values when you open the bookmark. + ///A placeholder looks like this:. + /// + public static string bookmarks_form_hint_text1 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {placeholder}. + /// + public static string bookmarks_form_hint_text2 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to — for example:. + /// + public static string bookmarks_form_hint_text3 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text3", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to https://www.bing.com/search?q={Query}. + /// + public static string bookmarks_form_hint_text4 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text4", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name (optional). /// public static string bookmarks_form_name_label { get { @@ -177,6 +268,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to (Refreshing bookmark...). + /// + public static string bookmarks_item_refreshing { + get { + return ResourceManager.GetString("bookmarks_item_refreshing", resourceCulture); + } + } + /// /// Looks up a localized string similar to Open in Terminal. /// @@ -194,5 +294,14 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { return ResourceManager.GetString("bookmarks_required_placeholder", resourceCulture); } } + + /// + /// Looks up a localized string similar to Unpin. + /// + public static string bookmarks_unpin_name { + get { + return ResourceManager.GetString("bookmarks_unpin_name", resourceCulture); + } + } } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx index 1038055b2d..45f57c0d77 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx @@ -140,7 +140,7 @@ "Terminal" should be the localized name of the Windows Terminal - Name + Name (optional) Save @@ -164,4 +164,41 @@ {0} is required {0} will be replaced by a parameter name provided by the user + + (Refreshing bookmark...) + + + Delete bookmark? + + + Are you sure you want to delete this bookmark? + + + Copy path + + + Copy address + + + Unpin + + + Failed to open {0} + + + You can add placeholders to bookmarks, and Command Palette will prompt you to enter their values when you open the bookmark. +A placeholder looks like this: + + + {placeholder} + + + — for example: + + + https://www.bing.com/search?q={Query} + + + Enter URL or file path, you can use {placeholders}, e.g. https://www.bing.com/search?q={Query} + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs new file mode 100644 index 0000000000..fd2736ebaf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs @@ -0,0 +1,547 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal sealed partial class BookmarkResolver : IBookmarkResolver +{ + private readonly IPlaceholderParser _placeholderParser; + + private const string UriSchemeShell = "shell"; + + public BookmarkResolver(IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(placeholderParser); + _placeholderParser = placeholderParser; + } + + public async Task<(bool Success, Classification Result)> TryClassifyAsync( + string? input, + CancellationToken cancellationToken = default) + { + try + { + var result = await Task.Run( + () => TryClassify(input, out var classification) + ? classification + : Classification.Unknown(input ?? string.Empty), + cancellationToken); + return (true, result); + } + catch (Exception ex) + { + Logger.LogError("Failed to classify", ex); + var result = Classification.Unknown(input ?? string.Empty); + return (false, result); + } + } + + public Classification ClassifyOrUnknown(string input) + { + return TryClassify(input, out var c) ? c : Classification.Unknown(input); + } + + private bool TryClassify(string? input, out Classification result) + { + try + { + bool success; + + if (string.IsNullOrWhiteSpace(input)) + { + result = Classification.Unknown(input ?? string.Empty); + success = false; + } + else + { + input = input.Trim(); + + // is placeholder? + var isPlaceholder = _placeholderParser.ParsePlaceholders(input, out var inputUntilFirstPlaceholder, out _); + success = ClassifyCore(input, out result, isPlaceholder, inputUntilFirstPlaceholder, _placeholderParser); + } + + return success; + } + catch (Exception ex) + { + Logger.LogError($"Failed to classify bookmark \"{input}\"", ex); + result = Classification.Unknown(input ?? string.Empty); + return false; + } + } + + private static bool ClassifyCore(string input, out Classification result, bool isPlaceholder, string inputUntilFirstPlaceholder, IPlaceholderParser placeholderParser) + { + // 1) Try URI parsing first (accepts custom schemes, e.g., shell:, ms-settings:) + // File URIs must start with "file:" to avoid confusion with local paths - which are handled below, in more sophisticated ways - + // as TryCreate would automatically add "file://" to bare paths like "C:\path\to\file.txt" which we don't want. + if (Uri.TryCreate(input, UriKind.Absolute, out var uri) + && !string.IsNullOrWhiteSpace(uri.Scheme) + && (uri.Scheme != Uri.UriSchemeFile || input.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + && uri.Scheme != UriSchemeShell) + { + // http/https → Url; any other scheme → Protocol (mailto:, ms-settings:, slack://, etc.) + var isWeb = uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; + + result = new Classification( + isWeb ? CommandKind.WebUrl : CommandKind.Protocol, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, // Shell picks the right handler + null, + isPlaceholder); + + return true; + } + + // 1a) We're a placeholder and start look like a protocol scheme (e.g. "myapp:{{placeholder}}") + if (isPlaceholder && UriHelper.TryGetScheme(inputUntilFirstPlaceholder, out var scheme, out _)) + { + // single letter schemes are probably drive letters, ignore, file and shell protocols are handled elsewhere + if (scheme.Length > 1 && scheme != Uri.UriSchemeFile && scheme != UriSchemeShell) + { + var isWeb = scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + + result = new Classification( + isWeb ? CommandKind.WebUrl : CommandKind.Protocol, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, // Shell picks the right handler + null, + isPlaceholder); + return true; + } + } + + // 2) Existing file/dir or "longest plausible prefix" + // Try to grow head (only for unquoted original) to include spaces until a path exists. + + // Find longest unquoted argument string + var (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input); + if (longestUnquotedHead == string.Empty) + { + (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitHeadAndArgs(input); + } + + var (headPath, tailArgs) = ExpandToBestExistingPath(longestUnquotedHead, tailAfterLongestUnquotedHead, isPlaceholder, placeholderParser); + if (headPath is not null) + { + var args = tailArgs ?? string.Empty; + + if (Directory.Exists(headPath)) + { + result = new Classification( + CommandKind.Directory, + input, + headPath, + string.Empty, + LaunchMethod.ExplorerOpen, + headPath, + isPlaceholder); + + return true; + } + + var ext = Path.GetExtension(headPath); + if (ShellHelpers.IsExecutableExtension(ext)) + { + result = new Classification( + CommandKind.FileExecutable, + input, + headPath, + args, + LaunchMethod.ShellExecute, // direct exec; or ShellExecute if you want verb support + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + var isShellLink = ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase); + var isUrlLink = ext.Equals(".url", StringComparison.OrdinalIgnoreCase); + if (isShellLink || isUrlLink) + { + // In the future we can fetch data out of the link + result = new Classification( + isUrlLink ? CommandKind.InternetShortcut : CommandKind.Shortcut, + input, + headPath, + string.Empty, + LaunchMethod.ShellExecute, + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + result = new Classification( + CommandKind.FileDocument, + input, + headPath, + args, + LaunchMethod.ShellExecute, + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + if (TryGetAumid(longestUnquotedHead, out var aumid)) + { + result = new Classification( + CommandKind.Aumid, + longestUnquotedHead, + aumid, + tailAfterLongestUnquotedHead, + LaunchMethod.ActivateAppId, + null, + isPlaceholder); + + return true; + } + + // 3) Bare command resolution via PATH + executable ext + // At this point 'head' is our best intended command token. + var (firstHead, tail) = SplitHeadAndArgs(input); + CommandLineHelper.ExpandPathToPhysicalFile(firstHead, true, out var head); + + // 3.1) UWP/AppX via AppsFolder/AUMID or pkgfamily!app + // Since the AUMID can be actually anything, we either take a full shell:AppsFolder\AUMID + // as entered and we try to detect packaged app ids (pkgfamily!app). + if (TryGetAumid(head, out var aumid2)) + { + result = new Classification( + CommandKind.Aumid, + head, + aumid2, + tail, + LaunchMethod.ActivateAppId, + null, + isPlaceholder); + + return true; + } + + // 3.2) It's a virtual shell item (e.g. Control Panel, Recycle Bin, This PC) + // Shell items that are backed by filesystem paths (e.g. Downloads) should be already handled above. + if (CommandLineHelper.HasShellPrefix(head)) + { + ShellNames.TryGetFriendlyName(input, out var displayName); + ShellNames.TryGetFileSystemPath(input, out var fsPath); + result = new Classification( + CommandKind.VirtualShellItem, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, + fsPath is not null && Directory.Exists(fsPath) ? fsPath : null, + isPlaceholder, + fsPath, + displayName); + return true; + } + + // 3.3) Search paths for the file name (with or without ext) + // If head is a file name with extension, we look only for that. If there's no extension + // we go and follow Windows Shell resolution rules. + if (TryResolveViaPath(head, out var resolvedFilePath)) + { + result = new Classification( + CommandKind.PathCommand, + input, + resolvedFilePath, + tail, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + + return true; + } + + // 3.4) If it looks like a path with ext but missing file, treat as document (Shell will handle assoc / error) + if (LooksPathy(head) && Path.HasExtension(head)) + { + var extension = Path.GetExtension(head); + + // if the path extension contains placeholders, we can't assume what it is so, skip it and treat it as unknown + var hasSpecificExtension = !isPlaceholder || !extension.Contains('{'); + if (hasSpecificExtension) + { + result = new Classification( + ShellHelpers.IsExecutableExtension(extension) ? CommandKind.FileExecutable : CommandKind.FileDocument, + input, + head, + tail, + LaunchMethod.ShellExecute, + HasDir(head) ? Path.GetDirectoryName(head) : null, + isPlaceholder); + + return true; + } + } + + // 4) looks like a web URL without scheme, but not like a file with extension + if (head.Contains('.', StringComparison.OrdinalIgnoreCase) && head.StartsWith("www", StringComparison.OrdinalIgnoreCase)) + { + // treat as URL, add https:// + var url = "https://" + input; + result = new Classification( + CommandKind.WebUrl, + input, + url, + string.Empty, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + return true; + } + + // 5) Fallback: let ShellExecute try the whole input + result = new Classification( + CommandKind.Unknown, + input, + head, + tail, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + + return true; + } + + private static (string Head, string Tail) SplitHeadAndArgs(string input) => CommandLineHelper.SplitHeadAndArgs(input); + + // Finds the best existing path prefix in an *unquoted* input by scanning + // whitespace boundaries. Prefers files to directories; for same kind, + // prefers the longer path. + // Returns (head, tail) or (null, null) if nothing found. + private static (string? Head, string? Tail) ExpandToBestExistingPath(string head, string tail, bool containsPlaceholders, IPlaceholderParser placeholderParser) + { + try + { + // This goes greedy from the longest head down to shortest; exactly opposite of what + // CreateProcess rules are for the first token. But here we operate with a slightly different goal. + var (greedyHead, greedyTail) = GreedyFind(head, containsPlaceholders, placeholderParser); + + // put tails back together: + return (Head: greedyHead, string.Join(" ", greedyTail, tail).Trim()); + } + catch (Exception ex) + { + Logger.LogError("Failed to find best path", ex); + throw; + } + } + + private static (string? Head, string? Tail) GreedyFind(string input, bool containsPlaceholders, IPlaceholderParser placeholderParser) + { + // Be greedy: try to find the longest existing path prefix + for (var i = input.Length; i >= 0; i--) + { + if (i < input.Length && !char.IsWhiteSpace(input[i])) + { + continue; + } + + var candidate = input.AsSpan(0, i).TrimEnd().ToString(); + if (candidate.Length == 0) + { + continue; + } + + // If we have placeholders, check if this candidate would contain a non-path placeholder + if (containsPlaceholders && ContainsNonPathPlaceholder(candidate, placeholderParser)) + { + continue; // Skip this candidate, try a shorter one + } + + try + { + if (CommandLineHelper.ExpandPathToPhysicalFile(candidate, true, out var full)) + { + var tail = i < input.Length ? input[i..].TrimStart() : string.Empty; + return (full, tail); + } + } + catch + { + // Ignore malformed paths; keep scanning + } + } + + return (null, null); + } + + // Attempts to guess if any placeholders in the candidate string are likely not part of a filesystem path. + private static bool ContainsNonPathPlaceholder(string candidate, IPlaceholderParser placeholderParser) + { + placeholderParser.ParsePlaceholders(candidate, out _, out var placeholders); + foreach (var match in placeholders) + { + var placeholderContext = GuessPlaceholderContextInFileSystemPath(candidate, match.Index); + + // If placeholder appears after what looks like a command-line flag/option + if (placeholderContext.IsAfterFlag) + { + return true; + } + + // If placeholder doesn't look like a typical path component + if (!placeholderContext.LooksLikePathComponent) + { + return true; + } + } + + return false; + } + + // Heuristically determines the context of a placeholder inside a filesystem-like input string. + // Sets: + // - IsAfterFlag: true if immediately preceded by a token that looks like a command-line flag prefix (" -", " /", " --"). + // - LooksLikePathComponent: true if (a) not after a flag or (b) nearby text shows path separators. + private static PlaceholderContext GuessPlaceholderContextInFileSystemPath(string input, int placeholderIndex) + { + var beforePlaceholder = input[..placeholderIndex].TrimEnd(); + + var isAfterFlag = beforePlaceholder.EndsWith(" -", StringComparison.OrdinalIgnoreCase) || + beforePlaceholder.EndsWith(" /", StringComparison.OrdinalIgnoreCase) || + beforePlaceholder.EndsWith(" --", StringComparison.OrdinalIgnoreCase); + + var looksLikePathComponent = !isAfterFlag; + + var nearbyText = input.Substring(Math.Max(0, placeholderIndex - 20), Math.Min(40, input.Length - Math.Max(0, placeholderIndex - 20))); + var hasPathSeparators = nearbyText.Contains('\\') || nearbyText.Contains('/'); + + if (!hasPathSeparators && isAfterFlag) + { + looksLikePathComponent = false; + } + + return new PlaceholderContext(isAfterFlag, looksLikePathComponent); + } + + private static bool TryGetAumid(string input, out string aumid) + { + // App ids are a lot of fun, since they can look like anything. + // And yes, they can contain spaces too, like Zoom: + // shell:AppsFolder\zoom.us.Zoom Video Meetings + // so unless that thing is quoted, we can't just assume the first token is the AUMID. + const string appsFolder = "shell:AppsFolder\\"; + + // Guard against null or empty input + if (string.IsNullOrEmpty(input)) + { + aumid = string.Empty; + return false; + } + + // Already a fully qualified AUMID path + if (input.StartsWith(appsFolder, StringComparison.OrdinalIgnoreCase)) + { + aumid = input; + return true; + } + + aumid = string.Empty; + return false; + } + + private static bool LooksPathy(string input) + { + // Basic: drive:\, UNC, relative with . or .., or has dir separator + if (input.Contains('\\') || input.Contains('/')) + { + return true; + } + + if (input is [_, ':', ..]) + { + return true; + } + + if (input.StartsWith(@"\\", StringComparison.InvariantCulture) || input.StartsWith("./", StringComparison.InvariantCulture) || input.StartsWith(".\\", StringComparison.InvariantCulture) || input.StartsWith("..\\", StringComparison.InvariantCulture)) + { + return true; + } + + return false; + } + + private static bool HasDir(string path) => !string.IsNullOrEmpty(Path.GetDirectoryName(path)); + + private static bool TryResolveViaPath(string head, out string resolvedFile) + { + resolvedFile = string.Empty; + + if (string.IsNullOrWhiteSpace(head)) + { + return false; + } + + if (Path.HasExtension(head) && ShellHelpers.FileExistInPath(head, out resolvedFile)) + { + return true; + } + + // If head has dir, treat as path probe + if (HasDir(head)) + { + if (Path.HasExtension(head)) + { + var p = TryProbe(Environment.CurrentDirectory, head); + if (p is not null) + { + resolvedFile = p; + return true; + } + + return false; + } + + foreach (var ext in ShellHelpers.ExecutableExtensions) + { + var p = TryProbe(null, head + ext); + if (p is not null) + { + resolvedFile = p; + return true; + } + } + + return false; + } + + return ShellHelpers.TryResolveExecutableAsShell(head, out resolvedFile); + } + + private static string? TryProbe(string? dir, string name) + { + try + { + var path = dir is null ? name : Path.Combine(dir, name); + if (File.Exists(path)) + { + return Path.GetFullPath(path); + } + } + catch + { + /* ignore */ + } + + return null; + } + + private record PlaceholderContext(bool IsAfterFlag, bool LooksLikePathComponent); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs new file mode 100644 index 0000000000..541ecdf19d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs @@ -0,0 +1,157 @@ +// 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.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public sealed partial class FaviconLoader : IFaviconLoader, IDisposable +{ + private readonly HttpClient _http = CreateClient(); + private bool _disposed; + + private static HttpClient CreateClient() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + }; + + var client = new HttpClient(handler, disposeHandler: true); + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WindowsCommandPalette/1.0"); + client.DefaultRequestHeaders.Accept.ParseAdd("image/*"); + + return client; + } + + public async Task TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default) + { + if (siteUri.Scheme != Uri.UriSchemeHttp && siteUri.Scheme != Uri.UriSchemeHttps) + { + return null; + } + + // 1) First attempt: favicon on the original authority (preserves port). + var first = BuildFaviconUri(siteUri); + + // Try download; if this fails (non-image or path lost), retry on final host. + var stream = await TryDownloadImageAsync(first, ct).ConfigureAwait(false); + if (stream is not null) + { + return stream; + } + + // 2) If the server redirected and "lost" the path, try /favicon.ico on the *final* host. + // We discover the final host by doing a HEAD/GET to the original URL and inspecting the final RequestUri. + var finalAuthority = await ResolveFinalAuthorityAsync(first, ct).ConfigureAwait(false); + if (finalAuthority is null || UriEqualsAuthority(first, finalAuthority)) + { + return null; + } + + var second = BuildFaviconUri(finalAuthority); + if (second == first) + { + return null; // nothing new to try + } + + return await TryDownloadImageAsync(second, ct).ConfigureAwait(false); + } + + private static Uri BuildFaviconUri(Uri anyUriOnSite) + { + var b = new UriBuilder(anyUriOnSite.Scheme, anyUriOnSite.Host) + { + Port = anyUriOnSite.IsDefaultPort ? -1 : anyUriOnSite.Port, + Path = "/favicon.ico", + }; + return b.Uri; + } + + private async Task ResolveFinalAuthorityAsync(Uri url, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + + // We only need headers to learn the final RequestUri after redirects + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + var final = resp.RequestMessage?.RequestUri; + return final is null ? null : new UriBuilder(final.Scheme, final.Host) + { + Port = final.IsDefaultPort ? -1 : final.Port, + Path = "/", + }.Uri; + } + + private async Task TryDownloadImageAsync(Uri url, CancellationToken ct) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (!resp.IsSuccessStatusCode) + { + return null; + } + + // If the redirect chain dumped us on an HTML page (common for root), bail. + var mediaType = resp.Content.Headers.ContentType?.MediaType; + if (mediaType is not null && + !mediaType.StartsWith("image", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var bytes = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + var stream = new InMemoryRandomAccessStream(); + + using (var output = stream.GetOutputStreamAt(0)) + using (var writer = new DataWriter(output)) + { + writer.WriteBytes(bytes); + await writer.StoreAsync().AsTask(ct); + await writer.FlushAsync().AsTask(ct); + } + + stream.Seek(0); + return stream; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } + + private static bool UriEqualsAuthority(Uri a, Uri b) + => a.Scheme.Equals(b.Scheme, StringComparison.OrdinalIgnoreCase) + && a.Host.Equals(b.Host, StringComparison.OrdinalIgnoreCase) + && (a.IsDefaultPort ? -1 : a.Port) == (b.IsDefaultPort ? -1 : b.Port); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _http.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs new file mode 100644 index 0000000000..5ed8133277 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs @@ -0,0 +1,15 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public interface IBookmarkIconLocator +{ + Task GetIconForPath(Classification classification, CancellationToken cancellationToken = default); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs new file mode 100644 index 0000000000..225c99d5a8 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs @@ -0,0 +1,16 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal interface IBookmarkResolver +{ + Task<(bool Success, Classification Result)> TryClassifyAsync(string input, CancellationToken cancellationToken = default); + + Classification ClassifyOrUnknown(string input); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs new file mode 100644 index 0000000000..cd9c3007de --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs @@ -0,0 +1,17 @@ +// 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.Threading; +using System.Threading.Tasks; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +/// +/// Service to load favicons for websites. +/// +public interface IFaviconLoader +{ + Task TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.cs new file mode 100644 index 0000000000..c357c7235b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public interface IPlaceholderParser +{ + bool ParsePlaceholders(string input, out string head, out List placeholders); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs new file mode 100644 index 0000000000..0a855f5886 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs @@ -0,0 +1,258 @@ +// 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.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.Win32; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal class IconLocator : IBookmarkIconLocator +{ + private readonly IFaviconLoader _faviconLoader; + + public IconLocator() + : this(new FaviconLoader()) + { + } + + private IconLocator(IFaviconLoader faviconLoader) + { + ArgumentNullException.ThrowIfNull(faviconLoader); + _faviconLoader = faviconLoader; + } + + public async Task GetIconForPath( + Classification classification, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(classification); + + var icon = classification.Kind switch + { + CommandKind.WebUrl => await TryGetWebIcon(classification.Target), + CommandKind.Protocol => await TryGetProtocolIcon(classification.Target), + CommandKind.FileExecutable => await TryGetExecutableIcon(classification.Target), + CommandKind.Unknown => FallbackIcon(classification), + _ => await MaybeGetIconForPath(classification.Target), + }; + + return icon ?? FallbackIcon(classification); + } + + private async Task TryGetWebIcon(string target) + { + // Get the base url up to the first placeholder + var placeholderIndex = target.IndexOf('{'); + var baseString = placeholderIndex > 0 ? target[..placeholderIndex] : target; + try + { + var uri = new Uri(baseString); + var iconStream = await _faviconLoader.TryGetFaviconAsync(uri, CancellationToken.None); + if (iconStream != null) + { + return IconInfo.FromStream(iconStream); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to get web bookmark favicon for " + baseString, ex); + } + + return null; + } + + private static async Task TryGetExecutableIcon(string target) + { + IIconInfo? icon = null; + var exeExists = false; + var fullExePath = string.Empty; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + // Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation + var pathResolutionTask = Task.Run( + () => + { + // Don't check cancellation token here - let the Task timeout handle it + exeExists = ShellHelpers.FileExistInPath(target, out fullExePath); + }, + CancellationToken.None); + + // Wait for either completion or timeout + pathResolutionTask.Wait(cts.Token); + } + catch (OperationCanceledException) + { + // Debug.WriteLine("Operation was canceled."); + } + + if (exeExists) + { + // If the executable exists, try to get the icon from the file + icon = await MaybeGetIconForPath(fullExePath); + if (icon is not null) + { + return icon; + } + } + + return icon; + } + + private static async Task TryGetProtocolIcon(string target) + { + // Special case for steam: protocol - use game icon + // Steam protocol have only a file name (steam.exe) associated with it, but is not + // in PATH or AppPaths. So we can't resolve it to an executable. But at the same time, + // this is a very common protocol, so we special-case it here. + if (target.StartsWith("steam:", StringComparison.OrdinalIgnoreCase)) + { + return Icons.BookmarkTypes.Game; + } + + // extract protocol from classification.Target (until the first ':'): + IconInfo? icon = null; + var colonIndex = target.IndexOf(':'); + string protocol; + if (colonIndex > 0) + { + protocol = target[..colonIndex]; + } + else + { + return icon; + } + + icon = await ThumbnailHelper.GetProtocolIconStream(protocol, true) is { } stream + ? IconInfo.FromStream(stream) + : null; + + if (icon is null) + { + var protocolIconPath = ProtocolIconResolver.GetIconString(protocol); + if (protocolIconPath is not null) + { + icon = new IconInfo(protocolIconPath); + } + } + + return icon; + } + + private static IconInfo FallbackIcon(Classification classification) + { + return classification.Kind switch + { + CommandKind.FileExecutable => Icons.BookmarkTypes.Application, + CommandKind.FileDocument => Icons.BookmarkTypes.FilePath, + CommandKind.Directory => Icons.BookmarkTypes.FolderPath, + CommandKind.PathCommand => Icons.BookmarkTypes.Command, + CommandKind.Aumid => Icons.BookmarkTypes.Application, + CommandKind.Shortcut => Icons.BookmarkTypes.Application, + CommandKind.InternetShortcut => Icons.BookmarkTypes.WebUrl, + CommandKind.WebUrl => Icons.BookmarkTypes.WebUrl, + CommandKind.Protocol => Icons.BookmarkTypes.Application, + _ => Icons.BookmarkTypes.Unknown, + }; + } + + private static async Task MaybeGetIconForPath(string target) + { + try + { + var stream = await ThumbnailHelper.GetThumbnail(target); + if (stream is not null) + { + return IconInfo.FromStream(stream); + } + + if (ShellNames.TryGetFileSystemPath(target, out var fileSystemPath)) + { + stream = await ThumbnailHelper.GetThumbnail(fileSystemPath); + if (stream is not null) + { + return IconInfo.FromStream(stream); + } + } + } + catch (Exception ex) + { + Logger.LogDebug($"Failed to load icon for {target}\n" + ex); + } + + return null; + } + + internal static class ProtocolIconResolver + { + /// + /// Gets the icon resource string for a given URI protocol (e.g. "steam" or "mailto"). + /// Returns something like "C:\Path\app.exe,0" or null if not found. + /// + public static string? GetIconString(string protocol) + { + try + { + if (string.IsNullOrWhiteSpace(protocol)) + { + return null; + } + + protocol = protocol.TrimEnd(':').ToLowerInvariant(); + + // Try HKCR\\DefaultIcon + using (var di = Registry.ClassesRoot.OpenSubKey(protocol + "\\DefaultIcon")) + { + var value = di?.GetValue(null) as string; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + // Fallback: HKCR\\shell\open\command + using (var cmd = Registry.ClassesRoot.OpenSubKey(protocol + "\\shell\\open\\command")) + { + var command = cmd?.GetValue(null) as string; + if (!string.IsNullOrWhiteSpace(command)) + { + var exe = ExtractExecutable(command); + if (!string.IsNullOrWhiteSpace(exe)) + { + return exe; // default index 0 implied + } + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to get protocol information from registry; will return nothing instead", ex); + } + + return null; + } + + private static string ExtractExecutable(string command) + { + command = command.Trim(); + + if (command.StartsWith('\"')) + { + var end = command.IndexOf('"', 1); + if (end > 1) + { + return command[1..end]; + } + } + + var space = command.IndexOf(' '); + return space > 0 ? command[..space] : command; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs new file mode 100644 index 0000000000..1a8254a33a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public sealed class PlaceholderInfo +{ + public string Name { get; } + + public int Index { get; } + + public PlaceholderInfo(string name, int index) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentOutOfRangeException.ThrowIfLessThan(index, 0); + + Name = name; + Index = index; + } + + private bool Equals(PlaceholderInfo other) => Name == other.Name && Index == other.Index; + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((PlaceholderInfo)obj); + } + + public override int GetHashCode() => HashCode.Combine(Name, Index); + + public static bool operator ==(PlaceholderInfo? left, PlaceholderInfo? right) + { + return Equals(left, right); + } + + public static bool operator !=(PlaceholderInfo? left, PlaceholderInfo? right) + { + return !Equals(left, right); + } + + public override string ToString() => Name; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs new file mode 100644 index 0000000000..7841e91c47 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public class PlaceholderInfoNameEqualityComparer : IEqualityComparer +{ + public static PlaceholderInfoNameEqualityComparer Instance { get; } = new(); + + public bool Equals(PlaceholderInfo? x, PlaceholderInfo? y) + { + if (x is null && y is null) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(PlaceholderInfo obj) + { + ArgumentNullException.ThrowIfNull(obj); + return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs new file mode 100644 index 0000000000..17c88a1ddf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public class PlaceholderParser : IPlaceholderParser +{ + public bool ParsePlaceholders(string input, out string head, out List placeholders) + { + ArgumentNullException.ThrowIfNull(input); + + head = string.Empty; + placeholders = []; + + if (string.IsNullOrEmpty(input)) + { + head = string.Empty; + return false; + } + + var foundPlaceholders = new List(); + var searchStart = 0; + var firstPlaceholderStart = -1; + var hasValidPlaceholder = false; + + while (searchStart < input.Length) + { + var openBrace = input.IndexOf('{', searchStart); + if (openBrace == -1) + { + break; + } + + var closeBrace = input.IndexOf('}', openBrace + 1); + if (closeBrace == -1) + { + break; + } + + // Extract potential placeholder name + var placeholderContent = input.Substring(openBrace + 1, closeBrace - openBrace - 1); + + // Check if it's a valid placeholder + if (!string.IsNullOrEmpty(placeholderContent) && + !IsGuidFormat(placeholderContent) && + IsValidPlaceholderName(placeholderContent)) + { + // Valid placeholder found + foundPlaceholders.Add(new PlaceholderInfo(placeholderContent, openBrace)); + hasValidPlaceholder = true; + + // Remember the first valid placeholder position + if (firstPlaceholderStart == -1) + { + firstPlaceholderStart = openBrace; + } + } + + // Continue searching after this brace pair + searchStart = closeBrace + 1; + } + + // Convert to Placeholder objects + placeholders = foundPlaceholders; + + if (hasValidPlaceholder) + { + head = input[..firstPlaceholderStart]; + return true; + } + else + { + head = input; + return false; + } + } + + private static bool IsValidPlaceholderName(string name) + { + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-')) + { + return false; + } + } + + return true; + } + + private static bool IsGuidFormat(string content) => Guid.TryParse(content, out _); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs deleted file mode 100644 index db60a31940..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading; -using System.Threading.Tasks; -using ManagedCommon; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Storage.Streams; -using Windows.System; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -public partial class UrlCommand : InvokableCommand -{ - private readonly Lazy _icon; - - public string Url { get; } - - public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; } - - public UrlCommand(BookmarkData data) - : this(data.Name, data.Bookmark) - { - } - - public UrlCommand(string name, string url) - { - Name = Properties.Resources.bookmarks_command_name_open; - - Url = url; - - _icon = new Lazy(() => - { - ShellHelpers.ParseExecutableAndArgs(Url, out var exe, out var args); - var t = GetIconForPath(exe); - t.Wait(); - return t.Result; - }); - } - - public override CommandResult Invoke() - { - var success = LaunchCommand(Url); - - return success ? CommandResult.Dismiss() : CommandResult.KeepOpen(); - } - - internal static bool LaunchCommand(string target) - { - ShellHelpers.ParseExecutableAndArgs(target, out var exe, out var args); - return LaunchCommand(exe, args); - } - - internal static bool LaunchCommand(string exe, string args) - { - if (string.IsNullOrEmpty(exe)) - { - var message = "No executable found in the command."; - Logger.LogError(message); - - return false; - } - - if (ShellHelpers.OpenInShell(exe, args)) - { - return true; - } - - // If we reach here, it means the command could not be executed - // If there aren't args, then try again as a https: uri - if (string.IsNullOrEmpty(args)) - { - var uri = GetUri(exe); - if (uri is not null) - { - _ = Launcher.LaunchUriAsync(uri); - } - else - { - Logger.LogError("The provided URL is not valid."); - } - - return true; - } - - return false; - } - - internal static Uri? GetUri(string url) - { - Uri? uri; - if (!Uri.TryCreate(url, UriKind.Absolute, out uri)) - { - if (!Uri.TryCreate("https://" + url, UriKind.Absolute, out uri)) - { - return null; - } - } - - return uri; - } - - public static async Task GetIconForPath(string target) - { - IconInfo? icon = null; - - // First, try to get the icon from the thumbnail helper - // This works for local files and folders - icon = await MaybeGetIconForPath(target); - if (icon is not null) - { - return icon; - } - - // Okay, that failed. Try to resolve the full path of the executable - var exeExists = false; - var fullExePath = string.Empty; - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); - - // Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation - var pathResolutionTask = Task.Run( - () => - { - // Don't check cancellation token here - let the Task timeout handle it - exeExists = ShellHelpers.FileExistInPath(target, out fullExePath); - }, - CancellationToken.None); - - // Wait for either completion or timeout - pathResolutionTask.Wait(cts.Token); - } - catch (OperationCanceledException) - { - // Debug.WriteLine("Operation was canceled."); - } - - if (exeExists) - { - // If the executable exists, try to get the icon from the file - icon = await MaybeGetIconForPath(fullExePath); - if (icon is not null) - { - return icon; - } - } - - // Get the base url up to the first placeholder - var placeholderIndex = target.IndexOf('{'); - var baseString = placeholderIndex > 0 ? target.Substring(0, placeholderIndex) : target; - try - { - var uri = GetUri(baseString); - if (uri is not null) - { - var hostname = uri.Host; - var faviconUrl = $"{uri.Scheme}://{hostname}/favicon.ico"; - icon = new IconInfo(faviconUrl); - } - } - catch (UriFormatException) - { - } - - // If we still don't have an icon, use the target as the icon - icon = icon ?? new IconInfo(target); - - return icon; - } - - private static async Task MaybeGetIconForPath(string target) - { - try - { - var stream = await ThumbnailHelper.GetThumbnail(target); - if (stream is not null) - { - var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); - return new IconInfo(data, data); - } - } - catch - { - } - - return null; - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs index cdf0ccfa47..1cb0c57f28 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs @@ -23,7 +23,7 @@ public partial class CalculatorCommandProvider : CommandProvider public CalculatorCommandProvider() { - Id = "Calculator"; + Id = "com.microsoft.cmdpal.builtin.calculator"; DisplayName = Resources.calculator_display_name; Icon = Icons.CalculatorIcon; Settings = ((SettingsManager)settings).Settings; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.dark.svg new file mode 100644 index 0000000000..8865472f05 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.light.svg new file mode 100644 index 0000000000..f39c0b594d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.dark.svg new file mode 100644 index 0000000000..d2658c1fde --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.light.svg new file mode 100644 index 0000000000..4485e39cfa --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.dark.svg new file mode 100644 index 0000000000..3e5845fac9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.light.svg new file mode 100644 index 0000000000..476f97953c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.dark.svg new file mode 100644 index 0000000000..f79782da20 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.light.svg new file mode 100644 index 0000000000..75bba0c080 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.dark.svg new file mode 100644 index 0000000000..6f34f9daa7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.light.svg new file mode 100644 index 0000000000..fb380fe84f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.dark.svg new file mode 100644 index 0000000000..162dedad90 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.light.svg new file mode 100644 index 0000000000..7aff1a515e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs index 76f8db9b62..ed2a02e8d5 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.Common.Messages; using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Messages; using Microsoft.CmdPal.Ext.ClipboardHistory.Models; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.ApplicationModel.DataTransfer; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs new file mode 100644 index 0000000000..9b73ade32b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs @@ -0,0 +1,35 @@ +// 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.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Abstraction for providers that can extract metadata and offer actions for a clipboard context. +/// +internal interface IClipboardMetadataProvider +{ + /// + /// Gets the section title to show in the UI for this provider's metadata. + /// + string SectionTitle { get; } + + /// + /// Returns true if this provider can produce metadata for the given item. + /// + bool CanHandle(ClipboardItem item); + + /// + /// Returns metadata elements for the UI. Caller decides section grouping. + /// + IEnumerable GetDetails(ClipboardItem item); + + /// + /// Returns context actions to be appended to MoreCommands. Use unique IDs for de-duplication. + /// + IEnumerable GetActions(ClipboardItem item); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs new file mode 100644 index 0000000000..429f6341f3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed record ImageMetadata( + uint Width, + uint Height, + double DpiX, + double DpiY, + ulong? StorageSize); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs new file mode 100644 index 0000000000..e69a7d3d9c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal static class ImageMetadataAnalyzer +{ + /// + /// Reads image metadata from a RandomAccessStreamReference without decoding pixels. + /// Returns oriented dimensions (EXIF rotation applied). + /// + public static async Task GetAsync(RandomAccessStreamReference reference) + { + ArgumentNullException.ThrowIfNull(reference); + + using IRandomAccessStream ras = await reference.OpenReadAsync().AsTask().ConfigureAwait(false); + var sizeBytes = TryGetSize(ras); + + // BitmapDecoder does not decode pixel data unless you ask it to, + // so this is fast and memory-friendly. + var decoder = await BitmapDecoder.CreateAsync(ras).AsTask().ConfigureAwait(false); + + // OrientedPixelWidth/Height account for EXIF orientation + var width = decoder.OrientedPixelWidth; + var height = decoder.OrientedPixelHeight; + + return new ImageMetadata( + Width: width, + Height: height, + DpiX: decoder.DpiX, + DpiY: decoder.DpiY, + StorageSize: sizeBytes); + } + + private static ulong? TryGetSize(IRandomAccessStream s) + { + try + { + // On file-backed streams this is accurate. + // On some URI/virtual streams this may be unsupported or 0. + var size = s.Size; + return size == 0 ? (ulong?)0 : size; + } + catch + { + return null; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs new file mode 100644 index 0000000000..09a3f33f2e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs @@ -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; +using System.Collections.Generic; +using ManagedCommon; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed class ImageMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Image metadata"; + + public bool CanHandle(ClipboardItem item) => item.IsImage; + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!CanHandle(item) || item.ImageData is null) + { + return result; + } + + try + { + var metadata = ImageMetadataAnalyzer.GetAsync(item.ImageData).GetAwaiter().GetResult(); + + result.Add(new DetailsElement + { + Key = "Dimensions", + Data = new DetailsLink($"{metadata.Width} x {metadata.Height}"), + }); + result.Add(new DetailsElement + { + Key = "DPI", + Data = new DetailsLink($"{metadata.DpiX:0.###} x {metadata.DpiY:0.###}"), + }); + + if (metadata.StorageSize != null) + { + result.Add(new DetailsElement + { + Key = "Storage size", + Data = new DetailsLink(SizeFormatter.FormatSize(metadata.StorageSize.Value)), + }); + } + } + catch (Exception ex) + { + Logger.LogDebug("Failed to retrieve image metadata:" + ex); + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) => []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs new file mode 100644 index 0000000000..1274d1ace9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal enum LineEndingType +{ + None, + Windows, // \r\n (CRLF) + Unix, // \n (LF) + Mac, // \r (CR) + Mixed, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs new file mode 100644 index 0000000000..1827fa8744 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Represents an action exposed by a metadata provider. +/// +/// Unique identifier for de-duplication (case-insensitive). +/// The actual context menu item to be shown. +internal readonly record struct ProviderAction(string Id, CommandContextItem Action); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs new file mode 100644 index 0000000000..a08ab32bc2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs @@ -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 System.Globalization; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Utility for formatting byte sizes to a human-readable string. +/// +internal static class SizeFormatter +{ + private const long KB = 1024; + private const long MB = 1024 * KB; + private const long GB = 1024 * MB; + + public static string FormatSize(long bytes) + { + return bytes switch + { + >= GB => string.Format(CultureInfo.CurrentCulture, "{0:F2} GB", (double)bytes / GB), + >= MB => string.Format(CultureInfo.CurrentCulture, "{0:F2} MB", (double)bytes / MB), + >= KB => string.Format(CultureInfo.CurrentCulture, "{0:F2} KB", (double)bytes / KB), + _ => string.Format(CultureInfo.CurrentCulture, "{0} B", bytes), + }; + } + + public static string FormatSize(ulong bytes) + { + // Use double for division to avoid overflow; thresholds mirror long version + if (bytes >= (ulong)GB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} GB", bytes / (double)GB); + } + + if (bytes >= (ulong)MB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} MB", bytes / (double)MB); + } + + if (bytes >= (ulong)KB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} KB", bytes / (double)KB); + } + + return string.Format(CultureInfo.CurrentCulture, "{0} B", bytes); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs new file mode 100644 index 0000000000..a51444a3af --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Detects when text content is a valid existing file or directory path and exposes basic metadata. +/// +internal sealed class TextFileSystemMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "File"; + + public bool CanHandle(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return false; + } + + var text = PathHelper.Unquote(item.Content); + return PathHelper.IsValidFilePath(text); + } + + public IEnumerable GetDetails(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + var result = new List(); + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return result; + } + + var path = PathHelper.Unquote(item.Content); + + if (PathHelper.IsSlow(path) || !PathHelper.Exists(path, out var isDirectory)) + { + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(Path.GetFileName(path)) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(path), path) }); + return result; + } + + try + { + if (!isDirectory) + { + var fi = new FileInfo(path); + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(fi.Name) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(fi.FullName), fi.FullName) }); + result.Add(new DetailsElement { Key = "Type", Data = new DetailsLink(fi.Extension) }); + result.Add(new DetailsElement { Key = "Size", Data = new DetailsLink(SizeFormatter.FormatSize(fi.Length)) }); + result.Add(new DetailsElement { Key = "Modified", Data = new DetailsLink(fi.LastWriteTime.ToString(CultureInfo.CurrentCulture)) }); + result.Add(new DetailsElement { Key = "Created", Data = new DetailsLink(fi.CreationTime.ToString(CultureInfo.CurrentCulture)) }); + } + else + { + var di = new DirectoryInfo(path); + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(di.Name) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(di.FullName), di.FullName) }); + result.Add(new DetailsElement { Key = "Type", Data = new DetailsLink("Folder") }); + result.Add(new DetailsElement { Key = "Modified", Data = new DetailsLink(di.LastWriteTime.ToString(CultureInfo.CurrentCulture)) }); + result.Add(new DetailsElement { Key = "Created", Data = new DetailsLink(di.CreationTime.ToString(CultureInfo.CurrentCulture)) }); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to retrieve file system metadata.", ex); + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + yield break; + } + + var path = PathHelper.Unquote(item.Content); + + if (PathHelper.IsSlow(path) || !PathHelper.Exists(path, out var isDirectory)) + { + // One anything + var open = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + + yield break; + } + + if (!isDirectory) + { + // Open file + var open = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + + // Show in folder (select) + var show = new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = WellKnownKeyChords.OpenFileLocation }; + yield return new ProviderAction(WellKnownActionIds.OpenLocation, show); + + // Copy path + var copy = new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = WellKnownKeyChords.CopyFilePath }; + yield return new ProviderAction(WellKnownActionIds.CopyPath, copy); + + // Open in console at file location + var openConsole = new CommandContextItem(OpenInConsoleCommand.FromFile(path)) { RequestedShortcut = WellKnownKeyChords.OpenInConsole }; + yield return new ProviderAction(WellKnownActionIds.OpenConsole, openConsole); + } + else + { + // Open folder + var openFolder = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, openFolder); + + // Open in console + var openConsole = new CommandContextItem(OpenInConsoleCommand.FromDirectory(path)) { RequestedShortcut = WellKnownKeyChords.OpenInConsole }; + yield return new ProviderAction(WellKnownActionIds.OpenConsole, openConsole); + + // Copy path + var copy = new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = WellKnownKeyChords.CopyFilePath }; + yield return new ProviderAction(WellKnownActionIds.CopyPath, copy); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs new file mode 100644 index 0000000000..726a15c37e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed record TextMetadata +{ + public int CharacterCount { get; init; } + + public int WordCount { get; init; } + + public int SentenceCount { get; init; } + + public int LineCount { get; init; } + + public int ParagraphCount { get; init; } + + public LineEndingType LineEnding { get; init; } + + public override string ToString() + { + return $"Characters: {CharacterCount}, Words: {WordCount}, Sentences: {SentenceCount}, Lines: {LineCount}, Paragraphs: {ParagraphCount}, Line Ending: {LineEnding}"; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs new file mode 100644 index 0000000000..83992f6428 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal partial class TextMetadataAnalyzer +{ + public TextMetadata Analyze(string input) + { + ArgumentNullException.ThrowIfNull(input); + + return new TextMetadata + { + CharacterCount = input.Length, + WordCount = CountWords(input), + SentenceCount = CountSentences(input), + LineCount = CountLines(input), + ParagraphCount = CountParagraphs(input), + LineEnding = DetectLineEnding(input), + }; + } + + private LineEndingType DetectLineEnding(string text) + { + var crlfCount = Regex.Matches(text, "\r\n").Count; + var lfCount = Regex.Matches(text, "(? 0 ? 1 : 0) + (lfCount > 0 ? 1 : 0) + (crCount > 0 ? 1 : 0); + + if (endingTypes > 1) + { + return LineEndingType.Mixed; + } + + if (crlfCount > 0) + { + return LineEndingType.Windows; + } + + if (lfCount > 0) + { + return LineEndingType.Unix; + } + + if (crCount > 0) + { + return LineEndingType.Mac; + } + + return LineEndingType.None; + } + + private int CountLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + return text.Count(c => c == '\n') + 1; + } + + private int CountParagraphs(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var paragraphs = ParagraphsRegex() + .Split(text) + .Count(static p => !string.IsNullOrWhiteSpace(p)); + + return paragraphs > 0 ? paragraphs : 1; + } + + private int CountWords(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + return Regex.Matches(text, @"\b\w+\b").Count; + } + + private int CountSentences(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var matches = SentencesRegex().Matches(text); + return matches.Count > 0 ? matches.Count : (text.Trim().Length > 0 ? 1 : 0); + } + + [GeneratedRegex(@"(\r?\n){2,}")] + private static partial Regex ParagraphsRegex(); + + [GeneratedRegex(@"[.!?]+(?=\s|$)")] + private static partial Regex SentencesRegex(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs new file mode 100644 index 0000000000..86e2a32270 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs @@ -0,0 +1,63 @@ +// 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 System.Globalization; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed class TextMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Text statistics"; + + public bool CanHandle(ClipboardItem item) => item.IsText; + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!CanHandle(item) || string.IsNullOrEmpty(item.Content)) + { + return result; + } + + var r = new TextMetadataAnalyzer().Analyze(item.Content); + + result.Add(new DetailsElement + { + Key = "Characters", + Data = new DetailsLink(r.CharacterCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Words", + Data = new DetailsLink(r.WordCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Sentences", + Data = new DetailsLink(r.SentenceCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Lines", + Data = new DetailsLink(r.LineCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Paragraphs", + Data = new DetailsLink(r.ParagraphCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Line Ending", + Data = new DetailsLink(r.LineEnding.ToString()), + }); + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) => []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs new file mode 100644 index 0000000000..0a2afc3e01 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Detects web links in text and shows normalized URL and key parts. +/// +internal sealed class WebLinkMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Link"; + + public bool CanHandle(ClipboardItem item) + { + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return false; + } + + if (!UrlHelper.IsValidUrl(item.Content)) + { + return false; + } + + var normalized = UrlHelper.NormalizeUrl(item.Content); + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return false; + } + + // Exclude file: scheme; it's handled by TextFileSystemMetadataProvider + return !uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + } + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return result; + } + + try + { + var normalized = UrlHelper.NormalizeUrl(item.Content); + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return result; + } + + // Skip file: at runtime as well (defensive) + if (uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + { + return result; + } + + result.Add(new DetailsElement { Key = "URL", Data = new DetailsLink(normalized) }); + result.Add(new DetailsElement { Key = "Host", Data = new DetailsLink(uri.Host) }); + + if (!uri.IsDefaultPort) + { + result.Add(new DetailsElement { Key = "Port", Data = new DetailsLink(uri.Port.ToString(CultureInfo.CurrentCulture)) }); + } + + if (!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/") + { + result.Add(new DetailsElement { Key = "Path", Data = new DetailsLink(uri.AbsolutePath) }); + } + + if (!string.IsNullOrEmpty(uri.Query)) + { + var q = uri.Query; + var count = q.Count(static c => c == '&') + (q.Length > 1 ? 1 : 0); + result.Add(new DetailsElement { Key = "Query params", Data = new DetailsLink(count.ToString(CultureInfo.CurrentCulture)) }); + } + + if (!string.IsNullOrEmpty(uri.Fragment)) + { + result.Add(new DetailsElement { Key = "Fragment", Data = new DetailsLink(uri.Fragment) }); + } + } + catch + { + // ignore malformed inputs + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) + { + if (!CanHandle(item)) + { + yield break; + } + + var normalized = UrlHelper.NormalizeUrl(item.Content!); + + var open = new CommandContextItem(new OpenUrlCommand(normalized)) + { + RequestedShortcut = KeyChords.OpenUrl, + }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs new file mode 100644 index 0000000000..7fa2a74aea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Well-known action id constants used to de-duplicate provider actions. +/// +internal static class WellKnownActionIds +{ + public const string Open = "open"; + public const string OpenLocation = "openLocation"; + public const string CopyPath = "copyPath"; + public const string OpenConsole = "openConsole"; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/PrimaryAction.cs similarity index 61% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/PrimaryAction.cs index 097aefdee9..11d7bd0d5d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/PrimaryAction.cs @@ -2,8 +2,11 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -namespace Microsoft.CmdPal.Common.Messages; +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; -public partial record HideWindowMessage() +internal enum PrimaryAction { + Default, + Paste, + Copy, } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/SettingsManager.cs index 6ccc987d52..40fda696a2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/SettingsManager.cs @@ -2,6 +2,7 @@ // 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.IO; using Microsoft.CmdPal.Ext.ClipboardHistory.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -26,10 +27,22 @@ internal sealed class SettingsManager : JsonSettingsManager, ISettingOptions Resources.settings_confirm_delete_description!, true); + private readonly ChoiceSetSetting _primaryAction = new( + Namespaced(nameof(PrimaryAction)), + Resources.settings_primary_action_title!, + Resources.settings_primary_action_description!, + [ + new ChoiceSetSetting.Choice(Resources.settings_primary_action_default!, PrimaryAction.Default.ToString("G")), + new ChoiceSetSetting.Choice(Resources.settings_primary_action_paste!, PrimaryAction.Paste.ToString("G")), + new ChoiceSetSetting.Choice(Resources.settings_primary_action_copy!, PrimaryAction.Copy.ToString("G")) + ]); + public bool KeepAfterPaste => _keepAfterPaste.Value; public bool DeleteFromHistoryRequiresConfirmation => _confirmDelete.Value; + public PrimaryAction PrimaryAction => Enum.TryParse(_primaryAction.Value, out var action) ? action : PrimaryAction.Default; + private static string SettingsJsonPath() { var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); @@ -45,6 +58,7 @@ internal sealed class SettingsManager : JsonSettingsManager, ISettingOptions Settings.Add(_keepAfterPaste); Settings.Add(_confirmDelete); + Settings.Add(_primaryAction); // Load settings from file upon initialization LoadSettings(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs new file mode 100644 index 0000000000..fe160e4c1b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.CmdPal.Core.Common.Helpers; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; + +internal static class UrlHelper +{ + /// + /// Validates if a string is a valid URL or file path + /// + /// The string to validate + /// True if the string is a valid URL or file path, false otherwise + internal static bool IsValidUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + // Trim whitespace for validation + url = url.Trim(); + + // URLs should not contain newlines + if (url.Contains('\n', StringComparison.Ordinal) || url.Contains('\r', StringComparison.Ordinal)) + { + return false; + } + + // Check if it's a valid file path (local or network) + if (PathHelper.IsValidFilePath(url)) + { + return true; + } + + if (!url.Contains('.', StringComparison.OrdinalIgnoreCase)) + { + // eg: 'com', 'org'. We don't think it's a valid url. + // This can simplify the logic of checking if the url is valid. + return false; + } + + if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + return true; + } + + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + if (Uri.IsWellFormedUriString("https://" + url, UriKind.Absolute)) + { + return true; + } + } + + return false; + } + + /// + /// Normalizes a URL or file path by adding appropriate schema if none is present + /// + /// The URL or file path to normalize + /// Normalized URL or file path with schema + internal static string NormalizeUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return url; + } + + // Trim whitespace + url = url.Trim(); + + // If it's a valid file path, convert to file:// URI + if (!url.StartsWith("file://", StringComparison.OrdinalIgnoreCase) && PathHelper.IsValidFilePath(url)) + { + try + { + // Convert to file URI (path is already absolute since we only accept absolute paths) + return new Uri(url).ToString(); + } + catch + { + // If conversion fails, return original + return url; + } + } + + if (!Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + url = "https://" + url; + } + } + + return url; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Icons.cs index 824a6a2233..4bb4c30586 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Icons.cs @@ -6,7 +6,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.ClipboardHistory; -internal sealed class Icons +internal static class Icons { internal static IconInfo CopyIcon { get; } = new("\xE8C8"); @@ -17,4 +17,21 @@ internal sealed class Icons internal static IconInfo DeleteIcon { get; } = new("\uE74D"); internal static IconInfo ClipboardListIcon { get; } = IconHelpers.FromRelativePath("Assets\\ClipboardHistory.svg"); + + internal static IconInfo Clipboard { get; } = Create("ic_fluent_clipboard_20_regular"); + + internal static IconInfo ClipboardImage { get; } = Create("ic_fluent_clipboard_image_20_regular"); + + internal static IconInfo ClipboardLetter { get; } = Create("ic_fluent_clipboard_letter_20_regular"); + + internal static IconInfo Copy { get; } = Create(" ic_fluent_copy_20_regular"); + + internal static IconInfo DocumentCopy { get; } = Create("ic_fluent_document_copy_20_regular"); + + internal static IconInfo ImageCopy { get; } = Create("ic_fluent_image_copy_20_regular"); + + private static IconInfo Create(string name) + { + return IconHelpers.FromRelativePaths($"Assets\\Icons\\{name}.light.svg", $"Assets\\Icons\\{name}.dark.svg"); + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs index 5d59d0d1f2..e30969b56c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs @@ -2,11 +2,6 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; @@ -16,4 +11,6 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory; internal static class KeyChords { internal static KeyChord DeleteEntry { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete); + + internal static KeyChord OpenUrl { get; } = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.O); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Messages/HideWindowMessage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Messages/HideWindowMessage.cs new file mode 100644 index 0000000000..3b7f5f2260 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Messages/HideWindowMessage.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Messages; + +/// +/// Message to request hiding the window. +/// +/// Yes, it's a little weird that this lives in the ClipboardHistory extension. +/// Until we need it somewhere else, this is good enough. +/// +public partial record HideWindowMessage() +{ +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj index 9b5057ecae..b0c0617c34 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj @@ -10,7 +10,8 @@ enable - + + @@ -39,5 +40,41 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs index dd66410e6d..f6c89f53e6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs @@ -3,14 +3,8 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using Microsoft.CmdPal.Common.Commands; -using Microsoft.CmdPal.Ext.ClipboardHistory.Commands; using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; -using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.ApplicationModel.DataTransfer; using Windows.Storage.Streams; @@ -41,126 +35,8 @@ public class ClipboardItem } [MemberNotNullWhen(true, nameof(ImageData))] - private bool IsImage => ImageData is not null; + internal bool IsImage => ImageData is not null; [MemberNotNullWhen(true, nameof(Content))] - private bool IsText => !string.IsNullOrEmpty(Content); - - public static List ShiftLinesLeft(List lines) - { - // Determine the minimum leading whitespace - var minLeadingWhitespace = lines - .Where(line => !string.IsNullOrWhiteSpace(line)) - .Min(line => line.TakeWhile(char.IsWhiteSpace).Count()); - - // Check if all lines have at least that much leading whitespace - if (lines.Any(line => line.TakeWhile(char.IsWhiteSpace).Count() < minLeadingWhitespace)) - { - return lines; // Return the original lines if any line doesn't have enough leading whitespace - } - - // Remove the minimum leading whitespace from each line - var shiftedLines = lines.Select(line => line.Substring(minLeadingWhitespace)).ToList(); - - return shiftedLines; - } - - public static List StripLeadingWhitespace(List lines) - { - // Determine the minimum leading whitespace - var minLeadingWhitespace = lines - .Min(line => line.TakeWhile(char.IsWhiteSpace).Count()); - - // Remove the minimum leading whitespace from each line - var shiftedLines = lines.Select(line => - line.Length >= minLeadingWhitespace - ? line.Substring(minLeadingWhitespace) - : line).ToList(); - - return shiftedLines; - } - - public ListItem ToListItem() - { - ListItem listItem; - - List metadata = []; - metadata.Add(new DetailsElement() - { - Key = "Copied on", - Data = new DetailsLink(Item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), - }); - - var deleteConfirmationCommand = new ConfirmableCommand() - { - Command = new DeleteItemCommand(this), - ConfirmationTitle = Properties.Resources.delete_confirmation_title!, - ConfirmationMessage = Properties.Resources.delete_confirmation_message!, - IsConfirmationRequired = () => Settings.DeleteFromHistoryRequiresConfirmation, - }; - var deleteContextMenuItem = new CommandContextItem(deleteConfirmationCommand) - { - IsCritical = true, - RequestedShortcut = KeyChords.DeleteEntry, - }; - - if (IsImage) - { - var iconData = new IconData(ImageData); - var heroImage = new IconInfo(iconData, iconData); - listItem = new(new CopyCommand(this, ClipboardFormat.Image)) - { - // Placeholder subtitle as there’s no BitmapImage dimensions to retrieve - Title = "Image Data", - Details = new Details() - { - HeroImage = heroImage, - Title = GetDataType(), - Body = Timestamp.ToString(CultureInfo.InvariantCulture), - Metadata = metadata.ToArray(), - }, - MoreCommands = [ - new CommandContextItem(new PasteCommand(this, ClipboardFormat.Image, Settings)), - new Separator(), - deleteContextMenuItem, - ], - }; - } - else if (IsText) - { - var splitContent = Content.Split("\n"); - var head = splitContent.AsSpan(0, Math.Min(3, splitContent.Length)).ToArray().ToList(); - var preview2 = string.Join( - "\n", - StripLeadingWhitespace(head)); - - listItem = new(new CopyCommand(this, ClipboardFormat.Text)) - { - Title = preview2, - - Details = new Details - { - Title = GetDataType(), - Body = $"```text\n{Content}\n```", - Metadata = metadata.ToArray(), - }, - MoreCommands = [ - new CommandContextItem(new PasteCommand(this, ClipboardFormat.Text, Settings)), - new Separator(), - deleteContextMenuItem, - ], - }; - } - else - { - listItem = new(new NoOpCommand()) - { - Title = "Unknown", - Subtitle = GetDataType(), - Details = new Details { Title = GetDataType() }, - }; - } - - return listItem; - } + internal bool IsText => !string.IsNullOrEmpty(Content); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs index c0d564eb5e..d17f6f5844 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs @@ -148,7 +148,7 @@ internal sealed partial class ClipboardHistoryListPage : ListPage var item = clipboardHistory[i]; if (item is not null) { - listItems.Add(item.ToListItem()); + listItems.Add(new ClipboardListItem(item, _settingsManager)); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs new file mode 100644 index 0000000000..865d8f6b91 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CmdPal.Common.Commands; +using Microsoft.CmdPal.Ext.ClipboardHistory.Commands; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models; + +internal sealed partial class ClipboardListItem : ListItem +{ + private static readonly IClipboardMetadataProvider[] MetadataProviders = + [ + new ImageMetadataProvider(), + new TextFileSystemMetadataProvider(), + new WebLinkMetadataProvider(), + new TextMetadataProvider(), + ]; + + private readonly SettingsManager _settingsManager; + private readonly ClipboardItem _item; + + private readonly CommandContextItem _deleteContextMenuItem; + private readonly CommandContextItem? _pasteCommand; + private readonly CommandContextItem? _copyCommand; + private readonly Lazy
_lazyDetails; + + public override IDetails? Details + { + get => _lazyDetails.Value; + set + { + } + } + + public ClipboardListItem(ClipboardItem item, SettingsManager settingsManager) + { + _item = item; + _settingsManager = settingsManager; + _settingsManager.Settings.SettingsChanged += SettingsOnSettingsChanged; + + _lazyDetails = new(() => CreateDetails()); + + var deleteConfirmationCommand = new ConfirmableCommand + { + Command = new DeleteItemCommand(_item), + ConfirmationTitle = Properties.Resources.delete_confirmation_title!, + ConfirmationMessage = Properties.Resources.delete_confirmation_message!, + IsConfirmationRequired = () => _settingsManager.DeleteFromHistoryRequiresConfirmation, + }; + _deleteContextMenuItem = new CommandContextItem(deleteConfirmationCommand) + { + IsCritical = true, + RequestedShortcut = KeyChords.DeleteEntry, + }; + + if (item.IsImage) + { + Title = "Image"; + + _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Image, _settingsManager)); + _copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Image)); + } + else if (item.IsText) + { + var splitContent = _item.Content?.Split("\n") ?? []; + var head = splitContent.Take(3); + var preview2 = string.Join( + "\n", + StripLeadingWhitespace(head)); + + Title = preview2; + + _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager)); + _copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Text)); + } + else + { + _pasteCommand = null; + _copyCommand = null; + } + + RefreshCommands(); + } + + private void SettingsOnSettingsChanged(object sender, Settings args) + { + RefreshCommands(); + } + + private void RefreshCommands() + { + if (_item is { IsText: false, IsImage: false }) + { + MoreCommands = [_deleteContextMenuItem]; + Icon = _settingsManager.PrimaryAction == PrimaryAction.Paste ? Icons.Clipboard : Icons.Copy; + } + + switch (_settingsManager.PrimaryAction) + { + case PrimaryAction.Paste: + Command = _pasteCommand?.Command; + MoreCommands = BuildMoreCommands(_copyCommand); + + if (_item.IsText) + { + Icon = Icons.ClipboardLetter; + } + else if (_item.IsImage) + { + Icon = Icons.ClipboardImage; + } + else + { + Icon = Icons.ClipboardImage; + } + + break; + case PrimaryAction.Default: + case PrimaryAction.Copy: + default: + Command = _copyCommand?.Command; + MoreCommands = BuildMoreCommands(_pasteCommand); + + if (_item.IsText) + { + Icon = Icons.DocumentCopy; + } + else if (_item.IsImage) + { + Icon = Icons.ImageCopy; + } + else + { + Icon = Icons.Copy; + } + + break; + } + } + + private IContextItem[] BuildMoreCommands(CommandContextItem? firstCommand) + { + var commands = new List(); + + if (firstCommand != null) + { + commands.Add(firstCommand); + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var temp = new List(); + foreach (var provider in MetadataProviders) + { + if (!provider.CanHandle(_item)) + { + continue; + } + + foreach (var action in provider.GetActions(_item)) + { + if (string.IsNullOrEmpty(action.Id) || !seen.Add(action.Id)) + { + continue; + } + + temp.Add(action.Action); + } + } + + if (temp.Count > 0) + { + if (commands.Count > 0) + { + commands.Add(new Separator()); + } + + commands.AddRange(temp); + } + + commands.Add(new Separator()); + commands.Add(_deleteContextMenuItem); + + return [.. commands]; + } + + private Details CreateDetails() + { + List metadata = []; + + foreach (var provider in MetadataProviders) + { + if (provider.CanHandle(_item)) + { + var details = provider.GetDetails(_item); + if (details.Any()) + { + metadata.Add(new DetailsElement + { + Key = provider.SectionTitle, + Data = new DetailsSeparator(), + }); + + metadata.AddRange(details); + } + } + } + + metadata.Add(new DetailsElement + { + Key = "General", + Data = new DetailsSeparator(), + }); + metadata.Add(new DetailsElement + { + Key = "Copied", + Data = new DetailsLink(_item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), + }); + + if (_item.IsImage) + { + var iconData = new IconData(_item.ImageData); + var heroImage = new IconInfo(iconData); + return new Details + { + Title = _item.GetDataType(), + HeroImage = heroImage, + Metadata = [.. metadata], + }; + } + + if (_item.IsText) + { + return new Details + { + Title = _item.GetDataType(), + Body = $"```text\n{_item.Content}\n```", + Metadata = [.. metadata], + }; + } + + return new Details { Title = _item.GetDataType() }; + } + + private static List StripLeadingWhitespace(IEnumerable lines) + { + // Determine the minimum leading whitespace + var minLeadingWhitespace = lines + .Min(static line => line.TakeWhile(char.IsWhiteSpace).Count()); + + // Remove the minimum leading whitespace from each line + var shiftedLines = lines.Select(line => + line.Length >= minLeadingWhitespace + ? line[minLeadingWhitespace..] + : line).ToList(); + + return shiftedLines; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..fbc2b32860 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.Designer.cs index a0b1882d14..f8695cae1b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -150,6 +150,15 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties { } } + /// + /// Looks up a localized string similar to Open URL. + /// + public static string open_url_command_name { + get { + return ResourceManager.GetString("open_url_command_name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Paste. /// @@ -178,7 +187,7 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties { } /// - /// Looks up a localized string similar to . + /// Looks up a localized string similar to Show a confirmation dialog when manually deleting an item. /// public static string settings_confirm_delete_description { get { @@ -187,7 +196,7 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties { } /// - /// Looks up a localized string similar to Show a confirmation dialog when manually deleting an item. + /// Looks up a localized string similar to Ask for confirmation before deleting items. /// public static string settings_confirm_delete_title { get { @@ -196,7 +205,7 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties { } /// - /// Looks up a localized string similar to . + /// Looks up a localized string similar to Keep items in clipboard history after pasting. /// public static string settings_keep_after_paste_description { get { @@ -205,12 +214,57 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties { } /// - /// Looks up a localized string similar to Keep items in clipboard history after pasting. + /// Looks up a localized string similar to Keep items after pasting. /// public static string settings_keep_after_paste_title { get { return ResourceManager.GetString("settings_keep_after_paste_title", resourceCulture); } } + + /// + /// Looks up a localized string similar to Copy to Clipboard. + /// + public static string settings_primary_action_copy { + get { + return ResourceManager.GetString("settings_primary_action_copy", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Default (Copy to Clipboard). + /// + public static string settings_primary_action_default { + get { + return ResourceManager.GetString("settings_primary_action_default", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Primary action (Enter key). + /// + public static string settings_primary_action_description { + get { + return ResourceManager.GetString("settings_primary_action_description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Paste. + /// + public static string settings_primary_action_paste { + get { + return ResourceManager.GetString("settings_primary_action_paste", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Primary action. + /// + public static string settings_primary_action_title { + get { + return ResourceManager.GetString("settings_primary_action_title", resourceCulture); + } + } } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx index e67ba1747c..56d0805871 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx @@ -151,16 +151,16 @@ Deleted from clipboard history - - - Keep items in clipboard history after pasting + + Keep items after pasting + - Show a confirmation dialog when manually deleting an item + Ask for confirmation before deleting items - + Show a confirmation dialog when manually deleting an item Delete item? @@ -168,4 +168,22 @@ Are you sure you want to delete this item from clipboard history? This action cannot be undone. + + Primary action + + + Primary action (Enter key) + + + Default (Copy to Clipboard) + + + Paste + + + Copy to Clipboard + + + Open URL + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/ExecuteActionCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Commands/ExecuteActionCommand.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/ExecuteActionCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Commands/ExecuteActionCommand.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs index 25ac912c40..7bb1fb4733 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using Microsoft.CmdPal.Common.Commands; +using Microsoft.CmdPal.Core.Common.Commands; using Microsoft.CmdPal.Ext.Indexer.Pages; using Microsoft.CmdPal.Ext.Indexer.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs index 2b408f24dc..5e044247ba 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs @@ -120,7 +120,6 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System var indexerPage = new IndexerPage(query, _searchEngine, _queryCookie, results); Title = string.Format(CultureInfo.CurrentCulture, fallbackItemSearchPageTitleCompositeFormat, query); Icon = Icons.FileExplorerIcon; - Subtitle = Resources.Indexer_Subtitle; Command = indexerPage; return; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs index f57b8a2d07..bd9759514c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs @@ -6,7 +6,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Indexer; -internal sealed class Icons +internal static class Icons { internal static IconInfo FileExplorerSegoeIcon { get; } = new("\uEC50"); @@ -19,4 +19,8 @@ internal sealed class Icons internal static IconInfo DocumentIcon { get; } = new("\uE8A5"); // Document internal static IconInfo FolderOpenIcon { get; } = new("\uE838"); // FolderOpen + + internal static IconInfo FilesIcon { get; } = new("\uF571"); // PrintAllPages + + internal static IconInfo FilterIcon { get; } = new("\uE71C"); // Filter } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs new file mode 100644 index 0000000000..bf2e5dc451 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer; + +internal sealed partial class SearchFilters : Filters +{ + public SearchFilters() + { + CurrentFilterId = "all"; + } + + public override IFilterItem[] GetFilters() + { + return [ + new Filter() { Id = "all", Name = Resources.Indexer_Filter_All, Icon = Icons.FilterIcon }, + new Separator(), + new Filter() { Id = "folders", Name = Resources.Indexer_Filter_Folders_Only, Icon = Icons.FolderOpenIcon }, + new Filter() { Id = "files", Name = Resources.Indexer_Filter_Files_Only, Icon = Icons.FilesIcon }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs index d2ea9b9b0c..ca1f215d4c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs @@ -33,7 +33,6 @@ public partial class IndexerCommandsProvider : CommandProvider new CommandItem(new IndexerPage()) { Title = Resources.Indexer_Title, - Subtitle = Resources.Indexer_Subtitle, } ]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/KeyChords.cs index 39ac4ae627..c0fd69d0f2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/KeyChords.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/KeyChords.cs @@ -2,7 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CmdPal.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.Ext.Indexer; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj index 6fafbc22b9..6b3b304825 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs index b440644dd7..79a47543b6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; -using Microsoft.CmdPal.Common.Commands; +using Microsoft.CmdPal.Core.Common.Commands; using Microsoft.CmdPal.Ext.Indexer.Data; using Microsoft.CmdPal.Ext.Indexer.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs index f03452effb..41abc0b018 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs @@ -4,7 +4,10 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Text.Encodings.Web; using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Indexer.Indexer; using Microsoft.CmdPal.Ext.Indexer.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -23,6 +26,9 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable private bool _isEmptyQuery = true; + private CommandItem _noSearchEmptyContent; + private CommandItem _nothingFoundEmptyContent; + public IndexerPage() { Id = "com.microsoft.indexer.fileSearch"; @@ -31,6 +37,12 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable PlaceholderText = Resources.Indexer_PlaceholderText; _searchEngine = new(); _queryCookie = 10; + + var filters = new SearchFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; + + CreateEmptyContent(); } public IndexerPage(string query, SearchEngine searchEngine, uint queryCookie, IList firstPageData) @@ -43,27 +55,90 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable initialQuery = query; SearchText = query; disposeSearchEngine = false; + + var filters = new SearchFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; + + CreateEmptyContent(); } - public override ICommandItem EmptyContent => GetEmptyContent(); + private void CreateEmptyContent() + { + _noSearchEmptyContent = new CommandItem(new NoOpCommand()) + { + Icon = Icon, + Subtitle = Resources.Indexer_NoSearchQueryMessageTip, + }; + + _nothingFoundEmptyContent = new CommandItem(new AnonymousCommand(StartManualSearch) { Name = Resources.Indexer_Command_SearchAllFiles! }) + { + Icon = Icon, + Title = Resources.Indexer_NoResultsMessage, + Subtitle = Resources.Indexer_NoResultsMessageTip, + MoreCommands = [ + new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }) + { + Title = Resources.Indexer_Command_SearchAllFiles!, + }, + ], + }; + } + + private void StartManualSearch() + { + // {20D04FE0-3AEA-1069-A2D8-08002B30309D} is CLSID for "This PC" + const string template = "search-ms:query={0}&crumb=location:::{{20D04FE0-3AEA-1069-A2D8-08002B30309D}}"; + var fullSearchText = FullSearchString(SearchText); + var encodedSearchText = UrlEncoder.Default.Encode(fullSearchText); + var command = string.Format(CultureInfo.CurrentCulture, template, encodedSearchText); + ShellHelpers.OpenInShell(command); + } + + public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent : _nothingFoundEmptyContent; + + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) + { + PerformSearch(SearchText); + } public override void UpdateSearchText(string oldSearch, string newSearch) { if (oldSearch != newSearch && newSearch != initialQuery) { - _ = Task.Run(() => - { - _isEmptyQuery = string.IsNullOrWhiteSpace(newSearch); - Query(newSearch); - LoadMore(); - OnPropertyChanged(nameof(EmptyContent)); - initialQuery = null; - }); + PerformSearch(newSearch); } } + private void PerformSearch(string newSearch) + { + var actualSearch = FullSearchString(newSearch); + _ = Task.Run(() => + { + _isEmptyQuery = string.IsNullOrWhiteSpace(actualSearch); + Query(actualSearch); + LoadMore(); + OnPropertyChanged(nameof(EmptyContent)); + initialQuery = null; + }); + } + public override IListItem[] GetItems() => [.. _indexerListItems]; + private string FullSearchString(string query) + { + switch (Filters.CurrentFilterId) + { + case "folders": + return $"{query} kind:folders"; + case "files": + return $"{query} kind:NOT folders"; + case "all": + default: + return query; + } + } + public override void LoadMore() { IsLoading = true; @@ -74,16 +149,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable RaiseItemsChanged(_indexerListItems.Count); } - private CommandItem GetEmptyContent() - { - return new CommandItem(new NoOpCommand()) - { - Icon = Icon, - Title = _isEmptyQuery ? Resources.Indexer_Subtitle : Resources.Indexer_NoResultsMessage, - Subtitle = Resources.Indexer_NoResultsMessageTip, - }; - } - private void Query(string query) { ++_queryCookie; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs index a78488a7f1..4394cd6697 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs @@ -96,6 +96,15 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } } + /// + /// Looks up a localized string similar to Open Windows Search settings. + /// + internal static string Indexer_Command_OpenIndexerSettings { + get { + return ResourceManager.GetString("Indexer_Command_OpenIndexerSettings", resourceCulture); + } + } + /// /// Looks up a localized string similar to Open path in console. /// @@ -123,6 +132,15 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } } + /// + /// Looks up a localized string similar to Search all files. + /// + internal static string Indexer_Command_SearchAllFiles { + get { + return ResourceManager.GetString("Indexer_Command_SearchAllFiles", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show in folder. /// @@ -159,6 +177,33 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } } + /// + /// Looks up a localized string similar to Files and folders. + /// + internal static string Indexer_Filter_All { + get { + return ResourceManager.GetString("Indexer_Filter_All", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Files. + /// + internal static string Indexer_Filter_Files_Only { + get { + return ResourceManager.GetString("Indexer_Filter_Files_Only", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Folders. + /// + internal static string Indexer_Filter_Folders_Only { + get { + return ResourceManager.GetString("Indexer_Filter_Folders_Only", resourceCulture); + } + } + /// /// Looks up a localized string similar to Find file from path. /// @@ -187,7 +232,8 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } /// - /// Looks up a localized string similar to Tip: Improve your search result using filters like in Windows Explorer. (For example: type:directory). + /// Looks up a localized string similar to Nothing was found in the indexed locations. + ///You can try searching all files on this PC or adjust your indexing settings.. /// internal static string Indexer_NoResultsMessageTip { get { @@ -195,6 +241,15 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } } + /// + /// Looks up a localized string similar to Tip: Refine your search using filters, just like in File Explorer (e.g., type:directory).. + /// + internal static string Indexer_NoSearchQueryMessageTip { + get { + return ResourceManager.GetString("Indexer_NoSearchQueryMessageTip", resourceCulture); + } + } + /// /// Looks up a localized string similar to Search for files and folders.... /// @@ -240,15 +295,6 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } } - /// - /// Looks up a localized string similar to Search files on this device. - /// - internal static string Indexer_Subtitle { - get { - return ResourceManager.GetString("Indexer_Subtitle", resourceCulture); - } - } - /// /// Looks up a localized string similar to Search files. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx index bbe8f0bd31..9ea1c4563e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx @@ -171,9 +171,6 @@ Only when file path exist - - Search files on this device - Search files @@ -184,6 +181,25 @@ No items found + Nothing was found in the indexed locations. +You can try searching all files on this PC or adjust your indexing settings. + + Tip: Refine your search using filters, just like in File Explorer (e.g., type:directory). + + Open Windows Search settings + + + Search all files + + + Files and folders + + + Folders + + + Files + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs index 22eca4cc3f..5d68f013a4 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs @@ -25,8 +25,7 @@ public partial class RegistryCommandsProvider : CommandProvider return [ new CommandItem(new RegistryListPage(_settingsManager)) { - Title = "Registry", - Subtitle = "Navigate the Windows registry", + Title = "Browse the Windows registry", } ]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs deleted file mode 100644 index f41f5e0ab7..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using Microsoft.CmdPal.Ext.Shell.Helpers; -using Microsoft.CmdPal.Ext.Shell.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Shell.Commands; - -internal sealed partial class ExecuteItem : InvokableCommand -{ - private readonly ISettingsInterface _settings; - private readonly RunAsType _runas; - - public string Cmd { get; internal set; } = string.Empty; - - private static readonly char[] Separator = [' ']; - - public ExecuteItem(string cmd, ISettingsInterface settings, RunAsType type = RunAsType.None) - { - if (type == RunAsType.Administrator) - { - Name = Properties.Resources.cmd_run_as_administrator; - Icon = Icons.AdminIcon; - } - else if (type == RunAsType.OtherUser) - { - Name = Properties.Resources.cmd_run_as_user; - Icon = Icons.UserIcon; - } - else - { - Name = Properties.Resources.generic_run_command; - Icon = Icons.RunV2Icon; - } - - Cmd = cmd; - _settings = settings; - _runas = type; - } - - private void Execute(Func startProcess, ProcessStartInfo info) - { - if (startProcess is null) - { - return; - } - - try - { - startProcess(info); - } - catch (FileNotFoundException e) - { - var name = "Plugin: " + Properties.Resources.cmd_plugin_name; - var message = $"{Properties.Resources.cmd_command_not_found}: {e.Message}"; - - // GH TODO #138 -- show this message once that's wired up - // _context.API.ShowMsg(name, message); - } - catch (Win32Exception e) - { - var name = "Plugin: " + Properties.Resources.cmd_plugin_name; - var message = $"{Properties.Resources.cmd_command_failed}: {e.Message}"; - ExtensionHost.LogMessage(new LogMessage() { Message = name + message }); - - // GH TODO #138 -- show this message once that's wired up - // _context.API.ShowMsg(name, message); - } - } - - public static ProcessStartInfo SetProcessStartInfo(string fileName, string workingDirectory = "", string arguments = "", string verb = "") - { - var info = new ProcessStartInfo - { - FileName = fileName, - WorkingDirectory = workingDirectory, - Arguments = arguments, - Verb = verb, - }; - - return info; - } - - private ProcessStartInfo PrepareProcessStartInfo(string command, RunAsType runAs = RunAsType.None) - { - command = Environment.ExpandEnvironmentVariables(command); - var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - - // Set runAsArg - var runAsVerbArg = string.Empty; - if (runAs == RunAsType.OtherUser) - { - runAsVerbArg = "runAsUser"; - } - else if (runAs == RunAsType.Administrator || _settings.RunAsAdministrator) - { - runAsVerbArg = "runAs"; - } - - if (Enum.TryParse(_settings.ShellCommandExecution, out var executionShell)) - { - ProcessStartInfo info; - if (executionShell == ExecutionShell.Cmd) - { - var arguments = _settings.LeaveShellOpen ? $"/k \"{command}\"" : $"/c \"{command}\" & pause"; - - info = SetProcessStartInfo("cmd.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.Powershell) - { - var arguments = _settings.LeaveShellOpen - ? $"-NoExit \"{command}\"" - : $"\"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\""; - info = SetProcessStartInfo("powershell.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.PowerShellSeven) - { - var arguments = _settings.LeaveShellOpen - ? $"-NoExit -C \"{command}\"" - : $"-C \"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\""; - info = SetProcessStartInfo("pwsh.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalCmd) - { - var arguments = _settings.LeaveShellOpen ? $"cmd.exe /k \"{command}\"" : $"cmd.exe /c \"{command}\" & pause"; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalPowerShell) - { - var arguments = _settings.LeaveShellOpen ? $"powershell -NoExit -C \"{command}\"" : $"powershell -C \"{command}\""; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalPowerShellSeven) - { - var arguments = _settings.LeaveShellOpen ? $"pwsh.exe -NoExit -C \"{command}\"" : $"pwsh.exe -C \"{command}\""; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.RunCommand) - { - // Open explorer if the path is a file or directory - if (Directory.Exists(command) || File.Exists(command)) - { - info = SetProcessStartInfo("explorer.exe", arguments: command, verb: runAsVerbArg); - } - else - { - var parts = command.Split(Separator, 2); - if (parts.Length == 2) - { - var filename = parts[0]; - if (ShellListPageHelpers.FileExistInPath(filename)) - { - var arguments = parts[1]; - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{filename} {arguments}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(filename, workingDirectory, arguments, runAsVerbArg); - } - } - else - { - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(command, verb: runAsVerbArg); - } - } - } - else - { - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(command, verb: runAsVerbArg); - } - } - } - } - else - { - throw new NotImplementedException(); - } - - info.UseShellExecute = true; - - _settings.AddCmdHistory(command); - - return info; - } - else - { - ExtensionHost.LogMessage(new LogMessage() { Message = "Error extracting setting" }); - throw new NotImplementedException(); - } - } - - public override CommandResult Invoke() - { - try - { - Execute(Process.Start, PrepareProcessStartInfo(Cmd, _runas)); - } - catch - { - ExtensionHost.LogMessage(new LogMessage() { Message = "Error starting the process " }); - } - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/OpenUrlWithHistoryCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs similarity index 51% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/OpenUrlWithHistoryCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs index 62b4761a34..f1fc878558 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/OpenUrlWithHistoryCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs @@ -2,9 +2,7 @@ // 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.IO; -using Microsoft.CommandPalette.Extensions; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Shell; @@ -13,18 +11,33 @@ internal sealed partial class OpenUrlWithHistoryCommand : OpenUrlCommand { private readonly Action? _addToHistory; private readonly string _url; + private readonly ITelemetryService? _telemetryService; - public OpenUrlWithHistoryCommand(string url, Action? addToHistory = null) + public OpenUrlWithHistoryCommand(string url, Action? addToHistory = null, ITelemetryService? telemetryService = null) : base(url) { _addToHistory = addToHistory; _url = url; + _telemetryService = telemetryService; } public override CommandResult Invoke() { _addToHistory?.Invoke(_url); - var result = base.Invoke(); - return result; + + var success = ShellHelpers.OpenInShell(_url); + var isWebUrl = false; + + if (Uri.TryCreate(_url, UriKind.Absolute, out var uri)) + { + if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + { + isWebUrl = true; + } + } + + _telemetryService?.LogOpenUri(_url, isWebUrl, success); + + return CommandResult.Dismiss(); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs index 79be63cd65..ab557ba258 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs @@ -2,14 +2,9 @@ // 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.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Ext.Shell.Helpers; using Microsoft.CmdPal.Ext.Shell.Pages; -using Microsoft.CmdPal.Ext.Shell.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Shell; @@ -19,18 +14,19 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos private static readonly char[] _systemDirectoryRoots = ['\\', '/']; private readonly Action? _addToHistory; + private readonly ITelemetryService _telemetryService; private CancellationTokenSource? _cancellationTokenSource; - private Task? _currentUpdateTask; - public FallbackExecuteItem(SettingsManager settings, Action? addToHistory) + public FallbackExecuteItem(SettingsManager settings, Action? addToHistory, ITelemetryService telemetryService) : base( new NoOpCommand() { Id = "com.microsoft.run.fallback" }, - Resources.shell_command_display_title) + ResourceLoaderInstance.GetString("shell_command_display_title")) { Title = string.Empty; - Subtitle = Properties.Resources.generic_run_command; + Subtitle = ResourceLoaderInstance.GetString("generic_run_command"); Icon = Icons.RunV2Icon; // Defined in Icons.cs and contains the execute command icon. _addToHistory = addToHistory; + _telemetryService = telemetryService; } public override void UpdateQuery(string query) @@ -43,44 +39,22 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos try { - // Save the latest update task - _currentUpdateTask = DoUpdateQueryAsync(query, cancellationToken); - } - catch (OperationCanceledException) - { - // DO NOTHING HERE - return; + DoUpdateQuery(query, cancellationToken); } catch (Exception) { // Handle other exceptions return; } - - // Await the task to ensure only the latest one gets processed - _ = ProcessUpdateResultsAsync(_currentUpdateTask); } - private async Task ProcessUpdateResultsAsync(Task updateTask) - { - try - { - await updateTask; - } - catch (OperationCanceledException) - { - // Handle cancellation gracefully - } - catch (Exception) - { - // Handle other exceptions - } - } - - private async Task DoUpdateQueryAsync(string query, CancellationToken cancellationToken) + private void DoUpdateQuery(string query, CancellationToken cancellationToken) { // Check for cancellation at the start - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } var searchText = query.Trim(); Expand(ref searchText); @@ -92,7 +66,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos return; } - ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args); + ShellListPageHelpers.NormalizeCommandLineAndArgs(searchText, out var exe, out var args); // Check for cancellation before file system operations cancellationToken.ThrowIfCancellationRequested(); @@ -108,22 +82,8 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); var timeoutToken = combinedCts.Token; - // Use Task.Run with timeout for file system operations - var fileSystemTask = Task.Run( - () => - { - exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath); - pathIsDir = Directory.Exists(exe); - }, - CancellationToken.None); - - // Wait for either completion or timeout - await fileSystemTask.WaitAsync(timeoutToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Main cancellation token was cancelled, re-throw - throw; + exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath, cancellationToken); + pathIsDir = Directory.Exists(exe); } catch (TimeoutException) { @@ -142,12 +102,15 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos } // Check for cancellation before updating UI properties - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } if (exeExists) { // TODO we need to probably get rid of the settings for this provider entirely - var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory); + var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory, telemetryService: _telemetryService); Title = exeItem.Title; Subtitle = exeItem.Subtitle; Icon = exeItem.Icon; @@ -156,7 +119,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos } else if (pathIsDir) { - var pathItem = new PathListItem(exe, query, _addToHistory); + var pathItem = new PathListItem(exe, query, _addToHistory, _telemetryService); Command = pathItem.Command; MoreCommands = pathItem.MoreCommands; Title = pathItem.Title; @@ -165,7 +128,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos } else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) { - Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory) { Result = CommandResult.Dismiss() }; + Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory, _telemetryService) { Result = CommandResult.Dismiss() }; Title = searchText; } else @@ -175,7 +138,10 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos } // Final cancellation check - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } } public void Dispose() diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs new file mode 100644 index 0000000000..6a9fe10562 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Storage.FileSystem; + +namespace Microsoft.CmdPal.Ext.Shell.Helpers; + +/// +/// Provides command line normalization functionality compatible with .NET +/// Native AOT. This is a C# port of the Profile::NormalizeCommandLine function +/// from the Windows Terminal codebase. +/// +/// It was ported from 7055b99ac on 2025-09-25 +/// +public static class CommandLineNormalizer +{ +#pragma warning disable SA1310 // Field names should not contain underscore + private const uint INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF; + + private const int MAX_PATH = 260; +#pragma warning restore SA1310 // Field names should not contain underscore + + /// + /// Normalizes a command line string by expanding environment variables, resolving executable paths, + /// and standardizing the format for comparison purposes. + /// + /// The command line string to normalize + /// A normalized command line string + /// + /// This function performs the following operations: + /// 1. Expands environment variables (e.g., %SystemRoot% -> C:\WINDOWS) + /// 2. Parses the command line into arguments, stripping quotes + /// 3. Resolves the executable path to an absolute, canonical path + /// 4. Reconstructs the command line with null separators between arguments + /// + /// Given a commandLine like: + /// * "C:\WINDOWS\System32\cmd.exe" + /// * "pwsh -WorkingDirectory ~" + /// * "C:\Program Files\PowerShell\7\pwsh.exe" + /// * "C:\Program Files\PowerShell\7\pwsh.exe -WorkingDirectory ~" + /// + /// This function returns: + /// * "C:\Windows\System32\cmd.exe" + /// * "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~" + /// * "C:\Program Files\PowerShell\7\pwsh.exe" + /// * "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~" + /// + /// The resulting strings are used for comparisons in profile matching. + /// + public static string NormalizeCommandLine(string commandLine, bool allowDirectory) + { + if (string.IsNullOrEmpty(commandLine)) + { + return string.Empty; + } + + // Turn "%SystemRoot%\System32\cmd.exe" into "C:\WINDOWS\System32\cmd.exe". + // We do this early, as environment variables might occur anywhere in the commandLine. + var normalized = ExpandEnvironmentVariables(commandLine); + + // One of the most important things this function does is to strip quotes. + // That way the commandLine "foo.exe -bar" and "\"foo.exe\" \"-bar\"" appear identical. + // We'll use CommandLineToArgvW for that as it's close to what CreateProcessW uses. + var argv = ParseCommandLineToArguments(normalized); + + if (argv.Length == 0) + { + return normalized; + } + + // The index of the first argument in argv after our executable in argv[0]. + // Given {"C:\Program Files\PowerShell\7\pwsh.exe", "-WorkingDirectory", "~"} this will be 1. + var startOfArguments = 1; + + // The given commandLine should start with an executable name or path. + // This loop tries to resolve relative paths, as well as executable names in %PATH% + // into absolute paths and normalizes them. + var executablePath = ResolveExecutablePath(argv, allowDirectory, ref startOfArguments); + + // We've (hopefully) finished resolving the path to the executable. + // We're now going to append all remaining arguments to the resulting string. + // If argv is {"C:\Program Files\PowerShell\7\pwsh.exe", "-WorkingDirectory", "~"}, + // then we'll get "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~" + var result = new StringBuilder(executablePath); + + for (var i = startOfArguments; i < argv.Length; i++) + { + result.Append('\0'); + result.Append(argv[i]); + } + + return result.ToString(); + } + + /// + /// Expands environment variables in a string using Windows API. + /// + private static string ExpandEnvironmentVariables(string input) + { + const int initialBufferSize = 1024; + var buffer = new char[initialBufferSize]; + + var result = PInvoke.ExpandEnvironmentStrings(input, buffer); + + if (result == 0) + { + // Failed to expand, return original string + return input; + } + + if (result > buffer.Length) + { + // Buffer was too small, resize and try again + buffer = new char[result]; + result = PInvoke.ExpandEnvironmentStrings(input, buffer); + + if (result == 0) + { + return input; + } + } + + return new string(buffer, 0, (int)result - 1); // -1 to exclude null terminator + } + + /// + /// Parses a command line string into arguments using CommandLineToArgvW. + /// + private static string[] ParseCommandLineToArguments(string commandLine) + { + unsafe + { + var argv = PInvoke.CommandLineToArgv(commandLine, out var argc); + + if (argv == null || argc == 0) + { + return Array.Empty(); + } + + try + { + var args = new string[argc]; + + for (var i = 0; i < argc; i++) + { + args[i] = new string(argv[i]); + } + + return args; + } + finally + { + PInvoke.LocalFree(new HLOCAL(argv)); + } + } + } + + /// + /// Resolves the executable path from the command line arguments. + /// Handles cases where the path contains spaces and was split during parsing. + /// + private static string ResolveExecutablePath(string[] argv, bool allowDirectory, ref int startOfArguments) + { + if (argv.Length == 0) + { + return string.Empty; + } + + // Try to resolve the executable path, handling cases where spaces in paths + // might have caused the path to be split across multiple arguments + for (var pathLength = 1; pathLength <= argv.Length; pathLength++) + { + // Build potential executable path by combining arguments + var pathBuilder = new StringBuilder(argv[0]); + for (var i = 1; i < pathLength; i++) + { + pathBuilder.Append(' '); + pathBuilder.Append(argv[i]); + } + + var candidatePath = pathBuilder.ToString(); + var resolvedPath = TryResolveExecutable(candidatePath, allowDirectory); + + if (!string.IsNullOrEmpty(resolvedPath)) + { + startOfArguments = pathLength; + return GetCanonicalPath(resolvedPath); + } + } + + // If we couldn't resolve the path, return the first argument as-is + startOfArguments = 1; + return argv[0]; + } + + /// + /// Attempts to resolve an executable path using SearchPathW. + /// + private static string TryResolveExecutable(string executableName, bool allowDirectory) + { + var buffer = new char[MAX_PATH]; + + unsafe + { + var outParam = default(PWSTR); // ultimately discarded + + var result = PInvoke.SearchPath( + null, // Use default search path + executableName, + ".exe", // Default extension + buffer, + &outParam); // We don't need the file part + + if (result == 0) + { + return string.Empty; + } + + if (result > buffer.Length) + { + // Buffer was too small, resize and try again + buffer = new char[result]; + result = PInvoke.SearchPath(null, executableName, ".exe", buffer, &outParam); + + if (result == 0) + { + return string.Empty; + } + } + + var resolvedPath = new string(buffer, 0, (int)result); + + // Verify the resolved path exists... + var attributes = PInvoke.GetFileAttributes(resolvedPath); + + // ... and if we don't want to allow directories, reject paths that are dirs + var rejectDirectory = !allowDirectory && + (attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0; + + return attributes == INVALID_FILE_ATTRIBUTES || + rejectDirectory ? + string.Empty : + resolvedPath; + } + } + + /// + /// Gets the canonical (absolute, normalized) path for a file. + /// + private static string GetCanonicalPath(string path) + { + try + { + return Path.GetFullPath(path); + } + catch + { + // If canonicalization fails, return the original path + return path; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs index eed1d71e49..15330a2751 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs @@ -2,13 +2,8 @@ // 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; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Shell.Commands; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Ext.Shell.Pages; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -16,37 +11,6 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers; public class ShellListPageHelpers { - private static readonly CompositeFormat CmdHasBeenExecutedTimes = System.Text.CompositeFormat.Parse(Properties.Resources.cmd_has_been_executed_times); - private readonly ISettingsInterface _settings; - - public ShellListPageHelpers(ISettingsInterface settings) - { - _settings = settings; - } - - private ListItem GetCurrentCmd(string cmd) - { - var result = new ListItem(new ExecuteItem(cmd, _settings)) - { - Title = cmd, - Subtitle = Properties.Resources.cmd_plugin_name + ": " + Properties.Resources.cmd_execute_through_shell, - Icon = new IconInfo(string.Empty), - }; - - return result; - } - - public List LoadContextMenus(ListItem listItem) - { - var resultList = new List - { - new(new ExecuteItem(listItem.Title, _settings, RunAsType.Administrator)), - new(new ExecuteItem(listItem.Title, _settings, RunAsType.OtherUser )), - }; - - return resultList; - } - internal static bool FileExistInPath(string filename) { return FileExistInPath(filename, out var _); @@ -54,11 +18,10 @@ public class ShellListPageHelpers internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null) { - // TODO! remove this method and just use ShellHelpers.FileExistInPath directly return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None); } - internal static ListItem? ListItemForCommandString(string query, Action? addToHistory) + internal static ListItem? ListItemForCommandString(string query, Action? addToHistory, ITelemetryService? telemetryService) { var li = new ListItem(); @@ -100,7 +63,7 @@ public class ShellListPageHelpers if (exeExists) { // TODO we need to probably get rid of the settings for this provider entirely - var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory); + var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory, telemetryService); li.Command = exeItem.Command; li.Title = exeItem.Title; li.Subtitle = exeItem.Subtitle; @@ -109,7 +72,7 @@ public class ShellListPageHelpers } else if (pathIsDir) { - var pathItem = new PathListItem(exe, query, addToHistory); + var pathItem = new PathListItem(exe, query, addToHistory, telemetryService); li.Command = pathItem.Command; li.Title = pathItem.Title; li.Subtitle = pathItem.Subtitle; @@ -118,7 +81,7 @@ public class ShellListPageHelpers } else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) { - li.Command = new OpenUrlWithHistoryCommand(searchText) { Result = CommandResult.Dismiss() }; + li.Command = new OpenUrlWithHistoryCommand(searchText, addToHistory, telemetryService) { Result = CommandResult.Dismiss() }; li.Title = searchText; } else @@ -133,4 +96,122 @@ public class ShellListPageHelpers return li; } + + /// + /// This is a version of ParseExecutableAndArgs that handles whitespace in + /// paths better. It will try to find the first matching executable in the + /// input string. + /// + /// If the input is quoted, it will treat everything inside the quotes as + /// the executable. If the input is not quoted, it will try to find the + /// first segment that matches + /// + public static void NormalizeCommandLineAndArgs(string input, out string executable, out string arguments) + { + var normalized = CommandLineNormalizer.NormalizeCommandLine(input, allowDirectory: true); + var segments = normalized.Split('\0', StringSplitOptions.RemoveEmptyEntries); + executable = string.Empty; + arguments = string.Empty; + if (segments.Length == 0) + { + return; + } + + executable = segments[0]; + if (segments.Length > 1) + { + arguments = ArgumentBuilder.BuildArguments(segments[1..]); + } + } + + private static class ArgumentBuilder + { + internal static string BuildArguments(string[] arguments) + { + if (arguments.Length <= 0) + { + return string.Empty; + } + + var stringBuilder = new StringBuilder(); + foreach (var argument in arguments) + { + AppendArgument(stringBuilder, argument); + } + + return stringBuilder.ToString(); + } + + private static void AppendArgument(StringBuilder stringBuilder, string argument) + { + if (stringBuilder.Length > 0) + { + stringBuilder.Append(' '); + } + + if (argument.Length == 0 || ShouldBeQuoted(argument)) + { + stringBuilder.Append('\"'); + var index = 0; + while (index < argument.Length) + { + var c = argument[index++]; + if (c == '\\') + { + var numBackSlash = 1; + while (index < argument.Length && argument[index] == '\\') + { + index++; + numBackSlash++; + } + + if (index == argument.Length) + { + stringBuilder.Append('\\', numBackSlash * 2); + } + else if (argument[index] == '\"') + { + stringBuilder.Append('\\', (numBackSlash * 2) + 1); + stringBuilder.Append('\"'); + index++; + } + else + { + stringBuilder.Append('\\', numBackSlash); + } + + continue; + } + + if (c == '\"') + { + stringBuilder.Append('\\'); + stringBuilder.Append('\"'); + continue; + } + + stringBuilder.Append(c); + } + + stringBuilder.Append('\"'); + } + else + { + stringBuilder.Append(argument); + } + } + + private static bool ShouldBeQuoted(string s) + { + foreach (var c in s) + { + if (char.IsWhiteSpace(c) || c == '\"') + { + return true; + } + } + + return false; + } + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/LocalSuppressions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/LocalSuppressions.cs new file mode 100644 index 0000000000..dc699880c9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/LocalSuppressions.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.LocalFreeSafeHandle")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj index 8f50d9141c..dcb618ec89 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj @@ -1,11 +1,7 @@  - - - - + - enable Microsoft.CmdPal.Ext.Shell $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal false @@ -15,9 +11,7 @@ - - - + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json new file mode 100644 index 0000000000..b1156c41b7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false, + "comInterop": { + "preserveSigMethods": [ "*" ] + } +} \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt new file mode 100644 index 0000000000..ea62d0c662 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt @@ -0,0 +1,22 @@ +GetCurrentPackageFullName +SetWindowLong +GetWindowLong +WINDOW_EX_STYLE +SFBS_FLAGS +MAX_PATH +GetDpiForWindow +GetWindowRect +GetMonitorInfo +SetWindowPos +MonitorFromWindow + +SHOW_WINDOW_CMD +ShellExecuteEx +SEE_MASK_INVOKEIDLIST + +ExpandEnvironmentStringsW +CommandLineToArgvW +SearchPathW +GetFileAttributesW +LocalFree +FILE_FLAGS_AND_ATTRIBUTES diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs similarity index 51% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs index 18f37818e6..b937ec7796 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs @@ -2,9 +2,8 @@ // 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.IO; -using Microsoft.CmdPal.Common.Commands; +using Microsoft.CmdPal.Core.Common.Commands; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; @@ -13,13 +12,18 @@ namespace Microsoft.CmdPal.Ext.Shell; internal sealed partial class PathListItem : ListItem { - private readonly Lazy _icon; - private readonly bool _isDirectory; + private readonly Lazy fetchedIcon; + private readonly bool isDirectory; + private readonly string path; - public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } + public override IIconInfo? Icon { get => fetchedIcon.Value ? _icon : _icon; set => base.Icon = value; } - public PathListItem(string path, string originalDir, Action? addToHistory) - : base(new OpenUrlWithHistoryCommand(path, addToHistory)) + private IIconInfo? _icon; + + internal bool IsDirectory => isDirectory; + + public PathListItem(string path, string originalDir, Action? addToHistory, ITelemetryService? telemetryService = null) + : base(new OpenUrlWithHistoryCommand(path, addToHistory, telemetryService)) { var fileName = Path.GetFileName(path); if (string.IsNullOrEmpty(fileName)) @@ -27,8 +31,8 @@ internal sealed partial class PathListItem : ListItem fileName = Path.GetFileName(Path.GetDirectoryName(path)) ?? string.Empty; } - _isDirectory = Directory.Exists(path); - if (_isDirectory) + isDirectory = Directory.Exists(path); + if (isDirectory) { if (!path.EndsWith('\\')) { @@ -41,40 +45,35 @@ internal sealed partial class PathListItem : ListItem } } + this.path = path; + Title = fileName; // Just the name of the file is the Title Subtitle = path; // What the user typed is the subtitle - // NOTE ME: - // If there are spaces on originalDir, trim them off, BEFORE combining originalDir and fileName. - // THEN add quotes at the end - - // Trim off leading & trailing quote, if there is one - var trimmed = originalDir.Trim('"'); - var originalPath = Path.Combine(trimmed, fileName); - var suggestion = originalPath; - var hasSpace = originalPath.Contains(' '); - if (hasSpace) - { - // wrap it in quotes - suggestion = string.Concat("\"", suggestion, "\""); - } - - TextToSuggest = suggestion; + TextToSuggest = path; MoreCommands = [ new CommandContextItem(new OpenWithCommand(path)), new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E) }, - new CommandContextItem(new CopyPathCommand(path) { Name = Properties.Resources.copy_path_command_name }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) }, + new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) }, new CommandContextItem(new OpenInConsoleCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R) }, new CommandContextItem(new OpenPropertiesCommand(path)), ]; - _icon = new Lazy(() => + fetchedIcon = new Lazy(() => { - var iconStream = ThumbnailHelper.GetThumbnail(path).Result; - var icon = iconStream is not null ? IconInfo.FromStream(iconStream) : - _isDirectory ? Icons.FolderIcon : Icons.RunV2Icon; - return icon; + _ = Task.Run(FetchIconAsync); + return true; }); } + + private async Task FetchIconAsync() + { + var iconStream = await ThumbnailHelper.GetThumbnail(path); + var icon = iconStream != null ? + IconInfo.FromStream(iconStream) : + isDirectory ? Icons.FolderIcon : Icons.RunV2Icon; + _icon = icon; + OnPropertyChanged(nameof(Icon)); + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs index a8d578939e..1a37093c77 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Threading.Tasks; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Ext.Shell.Helpers; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Storage.Streams; @@ -15,6 +15,7 @@ internal sealed partial class RunExeItem : ListItem { private readonly Lazy _icon; private readonly Action? _addToHistory; + private readonly ITelemetryService? _telemetryService; public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } @@ -26,13 +27,18 @@ internal sealed partial class RunExeItem : ListItem private string FullString => string.IsNullOrEmpty(_args) ? Exe : $"{Exe} {_args}"; - public RunExeItem(string exe, string args, string fullExePath, Action? addToHistory) + public RunExeItem( + string exe, + string args, + string fullExePath, + Action? addToHistory, + ITelemetryService? telemetryService = null) { FullExePath = fullExePath; Exe = exe; var command = new AnonymousCommand(Run) { - Name = Properties.Resources.generic_run_command, + Name = ResourceLoaderInstance.GetString("generic_run_command"), Result = CommandResult.Dismiss(), }; Command = command; @@ -46,6 +52,7 @@ internal sealed partial class RunExeItem : ListItem }); _addToHistory = addToHistory; + _telemetryService = telemetryService; UpdateArgs(args); @@ -53,13 +60,13 @@ internal sealed partial class RunExeItem : ListItem new CommandContextItem( new AnonymousCommand(RunAsAdmin) { - Name = Properties.Resources.cmd_run_as_administrator, + Name = ResourceLoaderInstance.GetString("cmd_run_as_administrator"), Icon = Icons.AdminIcon, }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter) }, new CommandContextItem( new AnonymousCommand(RunAsOther) { - Name = Properties.Resources.cmd_run_as_user, + Name = ResourceLoaderInstance.GetString("cmd_run_as_user"), Icon = Icons.UserIcon, }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U) }, ]; @@ -97,20 +104,26 @@ internal sealed partial class RunExeItem : ListItem { _addToHistory?.Invoke(FullString); - ShellHelpers.OpenInShell(FullExePath, _args); + var success = ShellHelpers.OpenInShell(FullExePath, _args); + + _telemetryService?.LogRunCommand(FullString, false, success); } public void RunAsAdmin() { _addToHistory?.Invoke(FullString); - ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator); + var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator); + + _telemetryService?.LogRunCommand(FullString, true, success); } public void RunAsOther() { _addToHistory?.Invoke(FullString); - ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser); + var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser); + + _telemetryService?.LogRunCommand(FullString, false, success); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs index fde17ba14c..82c6c2ddbc 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs @@ -2,15 +2,8 @@ // 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.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Ext.Shell.Helpers; -using Microsoft.CmdPal.Ext.Shell.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -18,13 +11,13 @@ namespace Microsoft.CmdPal.Ext.Shell.Pages; internal sealed partial class ShellListPage : DynamicListPage, IDisposable { - private readonly ShellListPageHelpers _helper; - - private readonly List _topLevelItems = []; private readonly Dictionary _historyItems = []; private readonly List _currentHistoryItems = []; private readonly IRunHistoryService _historyService; + private readonly ITelemetryService? _telemetryService; + + private readonly Dictionary _currentPathItems = new(); private ListItem? _exeItem; private List _pathItems = []; @@ -35,27 +28,26 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable private bool _loadedInitialHistory; - public ShellListPage(ISettingsInterface settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false) + private string _currentSubdir = string.Empty; + + public ShellListPage( + ISettingsInterface settingsManager, + IRunHistoryService runHistoryService, + ITelemetryService? telemetryService) { Icon = Icons.RunV2Icon; Id = "com.microsoft.cmdpal.shell"; - Name = Resources.cmd_plugin_name; - PlaceholderText = Resources.list_placeholder_text; - _helper = new(settingsManager); + Name = ResourceLoaderInstance.GetString("cmd_plugin_name"); + PlaceholderText = ResourceLoaderInstance.GetString("list_placeholder_text"); _historyService = runHistoryService; + _telemetryService = telemetryService; EmptyContent = new CommandItem() { - Title = Resources.cmd_plugin_name, + Title = ResourceLoaderInstance.GetString("cmd_plugin_name"), Icon = Icons.RunV2Icon, - Subtitle = Resources.list_placeholder_text, + Subtitle = ResourceLoaderInstance.GetString("list_placeholder_text"), }; - - if (addBuiltins) - { - // here, we _could_ add built-in providers if we wanted. links to apps, calc, etc. - // That would be a truly run-first experience - } } public override void UpdateSearchText(string oldSearch, string newSearch) @@ -123,8 +115,13 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable private async Task BuildListItemsForSearchAsync(string newSearch, CancellationToken cancellationToken) { + var timer = System.Diagnostics.Stopwatch.StartNew(); + // Check for cancellation at the start - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } // If the search text is the start of a path to a file (it might be a // UNC path), then we want to list all the files that start with that text: @@ -136,7 +133,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable var expanded = Environment.ExpandEnvironmentVariables(searchText); // Check for cancellation after environment expansion - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } // TODO we can be smarter about only re-reading the filesystem if the // new search is just the oldSearch+some chars @@ -152,14 +152,12 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable return; } - ShellHelpers.ParseExecutableAndArgs(expanded, out var exe, out var args); - - // Check for cancellation before file system operations - cancellationToken.ThrowIfCancellationRequested(); - // Reset the path resolution flag var couldResolvePath = false; + var exe = string.Empty; + var args = string.Empty; + var exeExists = false; var fullExePath = string.Empty; var pathIsDir = false; @@ -175,6 +173,8 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable var pathResolutionTask = Task.Run( () => { + ShellListPageHelpers.NormalizeCommandLineAndArgs(expanded, out exe, out args); + // Don't check cancellation token here - let the Task timeout handle it exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath); pathIsDir = Directory.Exists(expanded); @@ -206,7 +206,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable couldResolvePath = false; } - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } _pathItems.Clear(); @@ -221,7 +224,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable } // Check for cancellation before creating exe items - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } if (couldResolvePath && exeExists) { @@ -263,7 +269,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable var filterHistory = (string query, KeyValuePair pair) => { // Fuzzy search on the key (command string) - var score = StringMatcher.FuzzySearch(query, pair.Key).Score; + var score = FuzzyStringMatcher.ScoreFuzzy(query, pair.Key); return score; }; @@ -278,17 +284,31 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable _currentHistoryItems.AddRange(filteredHistory); // Final cancellation check - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } + + timer.Stop(); + _telemetryService?.LogRunQuery(newSearch, GetItems().Length, (ulong)timer.ElapsedMilliseconds); } - private static ListItem PathToListItem(string path, string originalPath, string args = "", Action? addToHistory = null) + private static ListItem PathToListItem(string path, string originalPath, string args = "", Action? addToHistory = null, ITelemetryService? telemetryService = null) { - var pathItem = new PathListItem(path, originalPath, addToHistory); + var pathItem = new PathListItem(path, originalPath, addToHistory, telemetryService); + + if (pathItem.IsDirectory) + { + return pathItem; + } // Is this path an executable? If so, then make a RunExeItem if (IsExecutable(path)) { - var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory); + var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory, telemetryService) + { + TextToSuggest = path, + }; exeItem.MoreCommands = [ .. exeItem.MoreCommands, @@ -306,24 +326,22 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable LoadInitialHistory(); } - var filteredTopLevel = ListHelpers.FilterList(_topLevelItems, SearchText); List uriItems = _uriItem is not null ? [_uriItem] : []; List exeItems = _exeItem is not null ? [_exeItem] : []; return exeItems - .Concat(filteredTopLevel) .Concat(_currentHistoryItems) .Concat(_pathItems) .Concat(uriItems) .ToArray(); } - internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action? addToHistory) + internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action? addToHistory, ITelemetryService? telemetryService) { // PathToListItem will return a RunExeItem if it can find a executable. // It will ALSO add the file search commands to the RunExeItem. - return PathToListItem(fullExePath, exe, args, addToHistory); + return PathToListItem(fullExePath, exe, args, addToHistory, telemetryService); } private void CreateAndAddExeItems(string exe, string args, string fullExePath) @@ -335,7 +353,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable } else { - _exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory); + _exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory, _telemetryService); } } @@ -389,7 +407,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable } // Check for cancellation before directory operations - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } var dirExists = Directory.Exists(directoryPath); @@ -408,30 +429,72 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable if (dirExists) { // Check for cancellation before file system enumeration - cancellationToken.ThrowIfCancellationRequested(); - - // Get all the files in the directory that start with the search text - // Run this on a background thread to avoid blocking - var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath, searchPattern), cancellationToken); - - // Check for cancellation after file enumeration - cancellationToken.ThrowIfCancellationRequested(); - - var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length)); - var originalBeginning = originalPath.Remove(originalPath.Length - searchPathTrailer.Length); - if (isDriveRoot) + if (cancellationToken.IsCancellationRequested) { - originalBeginning = string.Concat(originalBeginning, '\\'); + return; } - // Create a list of commands for each file - var commands = files.Select(f => PathToListItem(f, originalBeginning)).ToList(); + // If the directory we're in changed, then first rebuild the cache + // of all the items in the directory, _then_ filter them below. + if (directoryPath != _currentSubdir) + { + // Get all the files in the directory. + // Run this on a background thread to avoid blocking + var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath), cancellationToken); - // Final cancellation check before updating results - cancellationToken.ThrowIfCancellationRequested(); + // Check for cancellation after file enumeration + if (cancellationToken.IsCancellationRequested) + { + return; + } - // Add the commands to the list - _pathItems = commands; + var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length)); + var originalBeginning = originalPath.EndsWith(searchPathTrailer, StringComparison.CurrentCultureIgnoreCase) ? + originalPath.Remove(originalPath.Length - searchPathTrailer.Length) : + originalPath; + + if (isDriveRoot) + { + originalBeginning = string.Concat(originalBeginning, '\\'); + } + + // Create a list of commands for each file + var newPathItems = files + .Select(f => PathToListItem(f, originalBeginning)) + .ToDictionary(item => item.Title, item => item); + + // Final cancellation check before updating results + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // Add the commands to the list + _pathItems.Clear(); + _currentSubdir = directoryPath; + _currentPathItems.Clear(); + foreach ((var k, IListItem v) in newPathItems) + { + _currentPathItems[k] = (ListItem)v; + } + } + + // Filter the items from this directory + var fuzzyString = searchPattern.TrimEnd('*'); + var newMatchedPathItems = new List(); + + foreach (var kv in _currentPathItems) + { + var score = string.IsNullOrEmpty(fuzzyString) ? + 1 : + FuzzyStringMatcher.ScoreFuzzy(fuzzyString, kv.Key); + if (score > 0) + { + newMatchedPathItems.Add(kv.Value); + } + } + + ListHelpers.InPlaceUpdateList(_pathItems, newMatchedPathItems); } else { @@ -458,7 +521,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable { var hist = _historyService.GetRunHistory(); var histItems = hist - .Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory))) + .Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory, _telemetryService))) .Where(tuple => tuple.Item2 is not null) .Select(tuple => (tuple.h, tuple.Item2!)) .ToList(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs new file mode 100644 index 0000000000..75e5d64dc3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Shell; + +internal static class ResourceLoaderInstance +{ + public static string GetString(string resourceKey) + { + return Properties.Resources.ResourceManager.GetString(resourceKey, Properties.Resources.Culture) ?? throw new InvalidOperationException($"Resource key '{resourceKey}' not found."); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs index 4200c3050a..e5949f8a02 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs @@ -97,7 +97,7 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties { } /// - /// Looks up a localized string similar to Executes commands (e.g. 'ping', 'cmd'). + /// Looks up a localized string similar to Execute system commands like 'ping' and 'cmd'. /// public static string cmd_plugin_description { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx index a2f4cfb64f..991869b8da 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx @@ -121,7 +121,7 @@ Run commands - Executes commands (e.g. 'ping', 'cmd') + Execute system commands like 'ping' and 'cmd' this command has been executed {0} times diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/ISettingsInterface.cs similarity index 100% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/ISettingsInterface.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/SettingsManager.cs similarity index 100% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/SettingsManager.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs index a4bbeec5ea..943a3a1c8f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs @@ -2,7 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Ext.Shell.Helpers; using Microsoft.CmdPal.Ext.Shell.Pages; using Microsoft.CmdPal.Ext.Shell.Properties; @@ -19,19 +19,21 @@ public partial class ShellCommandsProvider : CommandProvider private readonly ShellListPage _shellListPage; private readonly FallbackCommandItem _fallbackItem; private readonly IRunHistoryService _historyService; + private readonly ITelemetryService _telemetryService; - public ShellCommandsProvider(IRunHistoryService runHistoryService) + public ShellCommandsProvider(IRunHistoryService runHistoryService, ITelemetryService telemetryService) { _historyService = runHistoryService; + _telemetryService = telemetryService; - Id = "Run"; + Id = "com.microsoft.cmdpal.builtin.run"; DisplayName = Resources.cmd_plugin_name; Icon = Icons.RunV2Icon; Settings = _settingsManager.Settings; - _shellListPage = new ShellListPage(_settingsManager, _historyService); + _shellListPage = new ShellListPage(_settingsManager, _historyService, _telemetryService); - _fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory); + _fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory, _telemetryService); _shellPageItem = new CommandItem(_shellListPage) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs index d97d352559..adaa9f7c26 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs @@ -47,8 +47,8 @@ internal sealed partial class FallbackSystemCommandItem : FallbackCommandItem { var title = command.Title; var subTitle = command.Subtitle; - var titleScore = StringMatcher.FuzzySearch(query, title).Score; - var subTitleScore = StringMatcher.FuzzySearch(query, subTitle).Score; + var titleScore = FuzzyStringMatcher.ScoreFuzzy(query, title); + var subTitleScore = FuzzyStringMatcher.ScoreFuzzy(query, subTitle); var maxScore = Math.Max(titleScore, subTitleScore); if (maxScore > resultScore) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs index 69e5c2ac78..f14011440d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs @@ -27,6 +27,14 @@ internal sealed class NetworkConnectionProperties /// private const int GreenCircleCharacter = 128994; + /// + /// Decimal unicode value for green circle emoji. + /// We need to generate it in the code because it does not render using Markdown emoji syntax or Unicode character syntax. + /// + /// + /// + private const int RedCircleCharacter = 128308; + /// /// Gets the name of the adapter /// @@ -170,7 +178,7 @@ internal sealed class NetworkConnectionProperties internal string GetAdapterDetails() { return $"**{Resources.Microsoft_plugin_sys_AdapterName}:** {Adapter}" + - $"\n\n**{Resources.Microsoft_plugin_sys_State}:** " + (State == OperationalStatus.Up ? char.ConvertFromUtf32(GreenCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Connected : ":red_circle: " + Resources.Microsoft_plugin_sys_Disconnected) + + $"\n\n**{Resources.Microsoft_plugin_sys_State}:** " + (State == OperationalStatus.Up ? char.ConvertFromUtf32(GreenCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Connected : char.ConvertFromUtf32(RedCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Disconnected) + $"\n\n**{Resources.Microsoft_plugin_sys_PhysicalAddress}:** {PhysicalAddress}" + $"\n\n**{Resources.Microsoft_plugin_sys_Speed}:** {GetFormattedSpeedValue(Speed)}" + $"\n\n**{Resources.Microsoft_plugin_sys_Type}:** {GetAdapterTypeAsString(Type)}" + @@ -184,7 +192,7 @@ internal sealed class NetworkConnectionProperties internal string GetConnectionDetails() { return $"**{Resources.Microsoft_plugin_sys_ConnectionName}:** {ConnectionName}" + - $"\n\n**{Resources.Microsoft_plugin_sys_State}:** " + (State == OperationalStatus.Up ? char.ConvertFromUtf32(GreenCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Connected : ":red_circle: " + Resources.Microsoft_plugin_sys_Disconnected) + + $"\n\n**{Resources.Microsoft_plugin_sys_State}:** " + (State == OperationalStatus.Up ? char.ConvertFromUtf32(GreenCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Connected : char.ConvertFromUtf32(RedCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Disconnected) + $"\n\n**{Resources.Microsoft_plugin_sys_Type}:** {GetAdapterTypeAsString(Type)}" + $"\n\n**{Resources.Microsoft_plugin_sys_Suffix}:** {Suffix}" + CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Ip4Address}:** ", IPv4) + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs index f2ec8c1218..0b1e1fe121 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs @@ -160,7 +160,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Adapter Details. + /// Looks up a localized string similar to Adapter details. /// public static string Microsoft_plugin_ext_adapter_details { get { @@ -169,7 +169,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Connection Details. + /// Looks up a localized string similar to Connection details. /// public static string Microsoft_plugin_ext_connection_details { get { @@ -187,7 +187,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Open System Command. + /// Looks up a localized string similar to Open system command. /// public static string Microsoft_plugin_ext_fallback_display_title { get { @@ -205,7 +205,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to System Commands. + /// Looks up a localized string similar to System commands. /// public static string Microsoft_plugin_ext_system_page_name { get { @@ -214,7 +214,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Windows System Commands. + /// Looks up a localized string similar to Windows system commands. /// public static string Microsoft_plugin_ext_system_page_title { get { @@ -628,7 +628,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to You are about to restart this computer, are you sure?. + /// Looks up a localized string similar to You are about to restart this computer. Are you sure?. /// public static string Microsoft_plugin_sys_restart_computer_confirmation { get { @@ -790,7 +790,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to DNS Suffix. + /// Looks up a localized string similar to DNS suffix. /// public static string Microsoft_plugin_sys_Suffix { get { @@ -817,7 +817,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to UEFI Firmware Settings. + /// Looks up a localized string similar to UEFI firmware settings. /// public static string Microsoft_plugin_sys_uefi { get { @@ -826,7 +826,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to You are about to reboot this computer into UEFI Firmware Settings menu, are you sure?. + /// Looks up a localized string similar to You are about to reboot this computer into UEFI firmware settings menu, are you sure?. /// public static string Microsoft_plugin_sys_uefi_confirmation { get { @@ -835,7 +835,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Reboot computer into UEFI Firmware Settings (Requires administrative permissions.). + /// Looks up a localized string similar to Reboot computer into UEFI firmware Settings (requires administrative permissions.). /// public static string Microsoft_plugin_sys_uefi_description { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx index bf7ab04da0..bc8ea5ec38 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx @@ -149,10 +149,10 @@ Shutdown - Connection Details + Connection details - Adapter Details + Adapter details Copy to clipboard @@ -161,10 +161,10 @@ Hide disconnected network info - Windows System Commands + Windows system commands - System Commands + System commands Adapter name @@ -327,7 +327,7 @@ This should align to the action in Windows of a restarting your computer. - You are about to restart this computer, are you sure? + You are about to restart this computer. Are you sure? This should align to the action in Windows of a restarting your computer. @@ -381,7 +381,7 @@ State - DNS Suffix + DNS suffix Tunnel @@ -391,15 +391,15 @@ Means type like category. Here it means network interface type (ethernet, wifi, ...). - UEFI Firmware Settings + UEFI firmware settings This should align to the action in Windows Recovery Environment that restart into uefi settings. - You are about to reboot this computer into UEFI Firmware Settings menu, are you sure? + You are about to reboot this computer into UEFI firmware settings menu, are you sure? This should align to the action in Windows Recovery Environment that restart into uefi settings. - Reboot computer into UEFI Firmware Settings (Requires administrative permissions.) + Reboot computer into UEFI firmware Settings (requires administrative permissions.) This should align to the action in Windows Recovery Environment that restart into uefi settings. @@ -415,7 +415,7 @@ Sleep - Open System Command + Execute system commands Restart Windows Explorer diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs index 54ec578dfa..4bc86c209d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs @@ -9,7 +9,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.System; -public partial class SystemCommandExtensionProvider : CommandProvider +public sealed partial class SystemCommandExtensionProvider : CommandProvider { private readonly ICommandItem[] _commands; private static readonly SettingsManager _settingsManager = new(); @@ -19,7 +19,7 @@ public partial class SystemCommandExtensionProvider : CommandProvider public SystemCommandExtensionProvider() { DisplayName = Resources.Microsoft_plugin_ext_system_page_name; - Id = "System"; + Id = "com.microsoft.cmdpal.builtin.system"; _commands = [ new CommandItem(Page) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs index 3f54ca8438..6938875f80 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Runtime.CompilerServices; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; @@ -64,12 +63,12 @@ internal sealed class AvailableResult public int Score(string query, string label, string tags) { // Get match for label (or for tags if label score is <1) - var score = StringMatcher.FuzzySearch(query, label).Score; + var score = FuzzyStringMatcher.ScoreFuzzy(query, label); if (score < 1) { foreach (var t in tags.Split(";")) { - var tagScore = StringMatcher.FuzzySearch(query, t.Trim()).Score / 2; + var tagScore = FuzzyStringMatcher.ScoreFuzzy(query, t.Trim()) / 2; if (tagScore > score) { score = tagScore / 2; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs index 9b820bbff8..2884cbbad2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs @@ -17,11 +17,6 @@ public sealed partial class TimeDateCalculator /// private const string InputDelimiter = "::"; - /// - /// A list of conjunctions that we ignore on search - /// - private static readonly string[] _conjunctionList = Resources.Microsoft_plugin_timedate_Search_ConjunctionList.Split("; "); - /// /// Searches for results /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs index ef4f2ede35..e3443c3ee6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs @@ -349,7 +349,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// - /// Looks up a localized string similar to Time and Date. + /// Looks up a localized string similar to Time and date. /// public static string Microsoft_plugin_timedate_main_page_title { get { @@ -448,7 +448,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// - /// Looks up a localized string similar to Provides time and date values in different formats. + /// Looks up a localized string similar to Show time and date values in different formats. /// public static string Microsoft_plugin_timedate_plugin_description { get { @@ -484,7 +484,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// - /// Looks up a localized string similar to Time and Date. + /// Looks up a localized string similar to Time and date. /// public static string Microsoft_plugin_timedate_plugin_name { get { @@ -501,15 +501,6 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } - /// - /// Looks up a localized string similar to "for; and; nor; but; or; so". - /// - public static string Microsoft_plugin_timedate_Search_ConjunctionList { - get { - return ResourceManager.GetString("Microsoft_plugin_timedate_Search_ConjunctionList", resourceCulture); - } - } - /// /// Looks up a localized string similar to Date and time; Time and Date; Custom format. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx index 35862592ca..eb248e3b1a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx @@ -202,7 +202,7 @@ 'UTC' means here 'Universal Time Convention' - Provides time and date values in different formats + Show time and date values in different formats Do not translate the placeholders like '{0}' because it will be replaced in code. @@ -215,7 +215,7 @@ Time - Time and Date + Time and date RFC1123 @@ -252,10 +252,6 @@ Current Time; Now Don't change order - - for; and; nor; but; or; so - List of conjunctions. We don't add 'yet' because this can be a synonym of 'now' which might be problematic on localized searches. - Second @@ -358,7 +354,7 @@ Error: Invalid input - Time and Date + Time and date A {0}format name{0}, a {0}valid date or time value{0}, or a {0}prefixed number{0}. To search for a format in a specific date/time please use the syntax {0}format::date/time/number{0}.{1}Supported prefixes:{2}'{0}u{0}' for Unix Timestamp{2}'{0}ums{0}' for Unix Timestamp in milliseconds{2}'{0}ft{0}' for Windows file time{2}'{0}oa{0}' for OLE Automation Date{2}'{0}exc{0}' for Excel's 1900 date value{2}'{0}exf{0}' for Excel's 1904 date value diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs index 26bd4d8453..0ad8c339ff 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs @@ -12,7 +12,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.TimeDate; -public partial class TimeDateCommandsProvider : CommandProvider +public sealed partial class TimeDateCommandsProvider : CommandProvider { private readonly CommandItem _command; private static readonly SettingsManager _settingsManager = new SettingsManager(); @@ -23,7 +23,7 @@ public partial class TimeDateCommandsProvider : CommandProvider public TimeDateCommandsProvider() { DisplayName = Resources.Microsoft_plugin_timedate_plugin_name; - Id = "DateTime"; + Id = "com.microsoft.cmdpal.builtin.datetime"; _command = new CommandItem(_timeDateExtensionPage) { Icon = _timeDateExtensionPage.Icon, diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.png new file mode 100644 index 0000000000..ff56efcd57 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.svg new file mode 100644 index 0000000000..8253a598d5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.png new file mode 100644 index 0000000000..12d96a6d0e Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.svg new file mode 100644 index 0000000000..311f98cdef --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.png deleted file mode 100644 index ce22b2dd9c..0000000000 Binary files a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.png and /dev/null differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.svg deleted file mode 100644 index 5028e6371f..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.svg +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Icons.cs index 10f6fd32c1..856d7614a2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Icons.cs @@ -6,7 +6,11 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WebSearch; -internal sealed class Icons +internal static class Icons { - internal static IconInfo WebSearch { get; } = IconHelpers.FromRelativePath("Assets\\WebSearch.png"); + internal static IconInfo WebSearch { get; } = IconHelpers.FromRelativePaths("Assets\\WebSearch.light.png", "Assets\\WebSearch.dark.png"); + + internal static IconInfo Search { get; } = new("\uE721"); + + internal static IconInfo History { get; } = new("\uE81C"); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj index 6e0a94987b..2f6665d8d0 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj @@ -31,13 +31,7 @@ - - - - - PreserveNewest - - + PreserveNewest diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs index 6fe4ae5a7c..641d5f6135 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs @@ -18,7 +18,6 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Pages; internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable { - private readonly IconInfo _newSearchIcon = new(string.Empty); private readonly ISettingsInterface _settingsManager; private readonly Lock _sync = new(); private static readonly CompositeFormat PluginInBrowserName = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_in_browser_name); @@ -32,7 +31,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable Name = Resources.command_item_title; Title = Resources.command_item_title; - Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png"); + Icon = Icons.WebSearch; Id = "com.microsoft.cmdpal.websearch"; _settingsManager = settingsManager; @@ -70,6 +69,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable var historyItem = items[index]; history.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, _settingsManager)) { + Icon = Icons.History, Title = historyItem.SearchString, Subtitle = historyItem.Timestamp.ToString("g", CultureInfo.InvariantCulture), }); @@ -82,7 +82,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable } } - private static IListItem[] Query(string query, List historySnapshot, ISettingsInterface settingsManager, IconInfo newSearchIcon) + private static IListItem[] Query(string query, List historySnapshot, ISettingsInterface settingsManager) { ArgumentNullException.ThrowIfNull(query); @@ -99,7 +99,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable { Title = searchTerm, Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName), - Icon = newSearchIcon, + Icon = Icons.Search, }; results.Add(result); } @@ -117,7 +117,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable historySnapshot = _historyItems; } - var items = Query(search ?? string.Empty, historySnapshot, _settingsManager, _newSearchIcon); + var items = Query(search ?? string.Empty, historySnapshot, _settingsManager); lock (_sync) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs index 9087ce0ee1..1a15991120 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs @@ -11,7 +11,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WebSearch; -public partial class WebSearchCommandsProvider : CommandProvider +public sealed partial class WebSearchCommandsProvider : CommandProvider { private readonly SettingsManager _settingsManager = new(); private readonly FallbackExecuteSearchItem _fallbackItem; @@ -22,7 +22,7 @@ public partial class WebSearchCommandsProvider : CommandProvider public WebSearchCommandsProvider() { - Id = "WebSearch"; + Id = "com.microsoft.cmdpal.builtin.websearch"; DisplayName = Resources.extension_name; Icon = Icons.WebSearch; Settings = _settingsManager.Settings; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj index 6d4de5629e..6fd229449e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj @@ -40,7 +40,7 @@ - + + true true true + + + false + false + false + + diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs index 70c8dab589..254dbf3eb9 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs @@ -5,6 +5,7 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using SamplePagesExtension.Pages; +using SamplePagesExtension.Pages.IssueSpecificPages; namespace SamplePagesExtension; @@ -48,6 +49,11 @@ public partial class SamplesListPage : ListPage Title = "Sample Icon Page", Subtitle = "A demo of using icons in various ways", }, + new ListItem(new SlowListPage()) + { + Title = "Slow loading list page", + Subtitle = "A demo of a list page that takes a while to load", + }, // Content pages new ListItem(new SampleContentPage()) @@ -76,11 +82,17 @@ public partial class SamplesListPage : ListPage Title = "Markdown with multiple blocks", Subtitle = "A page with multiple blocks of rendered markdown", }, - new ListItem(new SampleMarkdownDetails()) + new ListItem(new SampleMarkdownDetails()) { Title = "Markdown with details", Subtitle = "A page with markdown and details", }, + new ListItem(new SampleMarkdownImagesPage()) + { + Title = "Markdown with images", + Subtitle = "A page with rendered markdown and images", + Icon = new IconInfo("\uee71"), + }, // Settings helpers new ListItem(new SampleSettingsPage()) @@ -95,6 +107,11 @@ public partial class SamplesListPage : ListPage { Title = "Evil samples", Subtitle = "Samples designed to break the palette in many different evil ways", + }, + new ListItem(new AllIssueSamplesIndexPage()) + { + Title = "Issue-specific samples", + Subtitle = "Samples designed to reproduce specific issues", } ]; diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandItem.cs index b1a0917260..cddb678fa3 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandItem.cs @@ -63,16 +63,17 @@ public partial class CommandItem : BaseObservable, ICommandItem } OnPropertyChanged(nameof(Command)); - if (string.IsNullOrWhiteSpace(_title)) + if (string.IsNullOrEmpty(_title)) { OnPropertyChanged(nameof(Title)); } } } - private static void OnCommandPropertyChanged(CommandItem instance, object source, IPropChangedEventArgs args) + private void OnCommandPropertyChanged(CommandItem instance, object source, IPropChangedEventArgs args) { - if (args.PropertyName == nameof(ICommand.Name)) + // command's name affects Title only if Title wasn't explicitly set + if (args.PropertyName == nameof(ICommand.Name) && string.IsNullOrEmpty(_title)) { instance.OnPropertyChanged(nameof(Title)); } @@ -98,13 +99,11 @@ public partial class CommandItem : BaseObservable, ICommandItem public CommandItem(ICommand command) { Command = command; - Title = command.Name; } public CommandItem(ICommandItem other) { Command = other.Command; - Title = other.Title; Subtitle = other.Subtitle; Icon = (IconInfo?)other.Icon; MoreCommands = other.MoreCommands; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/ConfirmableCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/ConfirmableCommand.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/ConfirmableCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/ConfirmableCommand.cs diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyPathCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyPathCommand.cs index 6ace309bd1..9dcc8f36fb 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyPathCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyPathCommand.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Globalization; +using System.Text; using Microsoft.CommandPalette.Extensions.Toolkit.Properties; namespace Microsoft.CommandPalette.Extensions.Toolkit; @@ -10,6 +12,8 @@ public partial class CopyPathCommand : InvokableCommand { internal static IconInfo CopyPath { get; } = new("\uE8c8"); // Copy + private static readonly CompositeFormat CopyFailedFormat = CompositeFormat.Parse(Resources.copy_failed); + private readonly string _path; public CommandResult Result { get; set; } = CommandResult.ShowToast(Resources.CopyPathTextCommand_Result); @@ -27,8 +31,15 @@ public partial class CopyPathCommand : InvokableCommand { ClipboardHelper.SetText(_path); } - catch + catch (Exception ex) { + ExtensionHost.LogMessage(new LogMessage("Copy failed: " + ex.Message) { State = MessageState.Error }); + return CommandResult.ShowToast( + new ToastArgs + { + Message = string.Format(CultureInfo.CurrentCulture, CopyFailedFormat, ex.Message), + Result = CommandResult.KeepOpen(), + }); } return Result; diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenFileCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenFileCommand.cs index fff7950f3d..192d6313fc 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenFileCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenFileCommand.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Diagnostics; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; namespace Microsoft.CommandPalette.Extensions.Toolkit; @@ -18,7 +19,7 @@ public partial class OpenFileCommand : InvokableCommand public OpenFileCommand(string fullPath) { this._fullPath = fullPath; - this.Name = "Open"; + this.Name = Resources.OpenFileCommand_Name; this.Icon = OpenFile; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenInConsoleCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs similarity index 57% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenInConsoleCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs index 37b82422d0..ff655387e0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenInConsoleCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs @@ -4,31 +4,33 @@ using System.ComponentModel; using System.Diagnostics; -using System.IO; -using ManagedCommon; -using Microsoft.CmdPal.Common.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; -namespace Microsoft.CmdPal.Common.Commands; +namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class OpenInConsoleCommand : InvokableCommand { - internal static IconInfo OpenInConsoleIcon { get; } = new("\uE756"); + internal static IconInfo OpenInConsoleIcon { get; } = new("\uE756"); // "CommandPrompt" private readonly string _path; + private bool _isDirectory; public OpenInConsoleCommand(string fullPath) { this._path = fullPath; - this.Name = Resources.Indexer_Command_OpenPathInConsole; + this.Name = Resources.OpenInConsoleCommand_Name; this.Icon = OpenInConsoleIcon; } + public static OpenInConsoleCommand FromDirectory(string directory) => new(directory) { _isDirectory = true }; + + public static OpenInConsoleCommand FromFile(string file) => new(file); + public override CommandResult Invoke() { using (var process = new Process()) { - process.StartInfo.WorkingDirectory = Path.GetDirectoryName(_path); + process.StartInfo.WorkingDirectory = _isDirectory ? _path : Path.GetDirectoryName(_path); process.StartInfo.FileName = "cmd.exe"; try @@ -37,7 +39,7 @@ public partial class OpenInConsoleCommand : InvokableCommand } catch (Win32Exception ex) { - Logger.LogError($"Unable to open '{_path}'", ex); + ExtensionHost.LogMessage(new LogMessage($"Unable to open '{_path}'\n{ex.Message}\n{ex.StackTrace}") { State = MessageState.Error }); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenPropertiesCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenPropertiesCommand.cs similarity index 82% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenPropertiesCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenPropertiesCommand.cs index b4833dc913..171a6c9910 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenPropertiesCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenPropertiesCommand.cs @@ -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 System; using System.Runtime.InteropServices; -using ManagedCommon; using ManagedCsWin32; -using Microsoft.CmdPal.Common.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; using Windows.Win32.UI.WindowsAndMessaging; -namespace Microsoft.CmdPal.Common.Commands; +namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class OpenPropertiesCommand : InvokableCommand { @@ -46,7 +43,7 @@ public partial class OpenPropertiesCommand : InvokableCommand public OpenPropertiesCommand(string fullPath) { this._path = fullPath; - this.Name = Resources.Indexer_Command_OpenProperties; + this.Name = Resources.OpenPropertiesCommand_Name; this.Icon = OpenPropertiesIcon; } @@ -58,7 +55,7 @@ public partial class OpenPropertiesCommand : InvokableCommand } catch (Exception ex) { - Logger.LogError("Error showing file properties: ", ex); + ExtensionHost.LogMessage(new LogMessage($"Error showing file properties '{_path}'\n{ex.Message}\n{ex.StackTrace}") { State = MessageState.Error }); } return CommandResult.Dismiss(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenWithCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenWithCommand.cs similarity index 90% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenWithCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenWithCommand.cs index 33bd83a20c..5cd11f8635 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Commands/OpenWithCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenWithCommand.cs @@ -4,11 +4,11 @@ using System.Runtime.InteropServices; using ManagedCsWin32; -using Microsoft.CmdPal.Common.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; using Windows.Win32.UI.WindowsAndMessaging; -namespace Microsoft.CmdPal.Common.Commands; +namespace Microsoft.CmdPal.Core.Common.Commands; public partial class OpenWithCommand : InvokableCommand { @@ -44,7 +44,7 @@ public partial class OpenWithCommand : InvokableCommand public OpenWithCommand(string fullPath) { this._path = fullPath; - this.Name = Resources.Indexer_Command_OpenWith; + this.Name = Resources.OpenWithCommand_Name; this.Icon = OpenWithIcon; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs new file mode 100644 index 0000000000..f4591bc443 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +// Inspired by the fuzzy.rs from edit.exe +public static class FuzzyStringMatcher +{ + private const int NOMATCH = 0; + + public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches = true) + { + var (s, _) = ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches); + return s; + } + + public static (int Score, List Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches) + { + if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle)) + { + return (NOMATCH, new List()); + } + + var target = haystack.ToCharArray(); + var query = needle.ToCharArray(); + + if (target.Length < query.Length) + { + return (NOMATCH, new List()); + } + + var targetUpper = FoldCase(haystack); + var queryUpper = FoldCase(needle); + var targetUpperChars = targetUpper.ToCharArray(); + var queryUpperChars = queryUpper.ToCharArray(); + + var area = query.Length * target.Length; + var scores = new int[area]; + var matches = new int[area]; + + for (var qi = 0; qi < query.Length; qi++) + { + var qiOffset = qi * target.Length; + var qiPrevOffset = qi > 0 ? (qi - 1) * target.Length : 0; + + for (var ti = 0; ti < target.Length; ti++) + { + var currentIndex = qiOffset + ti; + var diagIndex = (qi > 0 && ti > 0) ? qiPrevOffset + ti - 1 : 0; + var leftScore = ti > 0 ? scores[currentIndex - 1] : 0; + var diagScore = (qi > 0 && ti > 0) ? scores[diagIndex] : 0; + var matchSeqLen = (qi > 0 && ti > 0) ? matches[diagIndex] : 0; + + var score = (diagScore == 0 && qi != 0) ? 0 : + ComputeCharScore( + query[qi], + queryUpperChars[qi], + ti != 0 ? target[ti - 1] : null, + target[ti], + targetUpperChars[ti], + matchSeqLen); + + var isValidScore = score != 0 && diagScore + score >= leftScore && + (allowNonContiguousMatches || qi > 0 || + targetUpperChars.Skip(ti).Take(queryUpperChars.Length).SequenceEqual(queryUpperChars)); + + if (isValidScore) + { + matches[currentIndex] = matchSeqLen + 1; + scores[currentIndex] = diagScore + score; + } + else + { + matches[currentIndex] = NOMATCH; + scores[currentIndex] = leftScore; + } + } + } + + var positions = new List(); + if (query.Length > 0 && target.Length > 0) + { + var qi = query.Length - 1; + var ti = target.Length - 1; + + while (true) + { + var index = (qi * target.Length) + ti; + if (matches[index] == NOMATCH) + { + if (ti == 0) + { + break; + } + + ti--; + } + else + { + positions.Add(ti); + if (qi == 0 || ti == 0) + { + break; + } + + qi--; + ti--; + } + } + + positions.Reverse(); + } + + return (scores[area - 1], positions); + } + + private static string FoldCase(string input) + { + return input.ToUpperInvariant(); + } + + private static int ComputeCharScore( + char query, + char queryLower, + char? targetPrev, + char targetCurr, + char targetLower, + int matchSeqLen) + { + if (!ConsiderAsEqual(queryLower, targetLower)) + { + return 0; + } + + var score = 1; // Character match bonus + + if (matchSeqLen > 0) + { + score += matchSeqLen * 5; // Consecutive match bonus + } + + if (query == targetCurr) + { + score += 1; // Same case bonus + } + + if (targetPrev.HasValue) + { + var sepBonus = ScoreSeparator(targetPrev.Value); + if (sepBonus > 0) + { + score += sepBonus; + } + else if (char.IsUpper(targetCurr) && matchSeqLen == 0) + { + score += 2; // CamelCase bonus + } + } + else + { + score += 8; // Start of word bonus + } + + return score; + } + + private static bool ConsiderAsEqual(char a, char b) + { + return a == b || (a == '/' && b == '\\') || (a == '\\' && b == '/'); + } + + private static int ScoreSeparator(char ch) + { + return ch switch + { + '/' or '\\' => 5, + '_' or '-' or '.' or ' ' or '\'' or '"' or ':' => 4, + _ => 0, + }; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs index 6a0dde88cc..98e2fae688 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs @@ -15,6 +15,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "Dictionary")] +[JsonSerializable(typeof(List>))] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true)] internal sealed partial class JsonSerializationContext : JsonSerializerContext { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs index b037941da4..0ffab0b7e4 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs @@ -6,7 +6,7 @@ using Windows.System; namespace Microsoft.CommandPalette.Extensions.Toolkit; -public partial class KeyChordHelpers +public static partial class KeyChordHelpers { public static KeyChord FromModifiers( bool ctrl = false, @@ -34,4 +34,28 @@ public partial class KeyChordHelpers { return FromModifiers(ctrl, alt, shift, win, (int)vkey, scanCode); } + + public static string FormatForDebug(KeyChord value) + { + var result = string.Empty; + + if (value.Modifiers.HasFlag(VirtualKeyModifiers.Control)) + { + result += "Ctrl+"; + } + + if (value.Modifiers.HasFlag(VirtualKeyModifiers.Shift)) + { + result += "Shift+"; + } + + if (value.Modifiers.HasFlag(VirtualKeyModifiers.Menu)) + { + result += "Alt+"; + } + + result += (VirtualKey)value.Vkey; + + return result; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs index 441de9c713..3847ab8e55 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs @@ -19,17 +19,17 @@ public partial class ListHelpers return 0; } - var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title); + var nameMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title); // var locNameMatch = StringMatcher.FuzzySearch(query, NameLocalized); - var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle); + var descriptionMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle); // var executableNameMatch = StringMatcher.FuzzySearch(query, ExePath); // var locExecutableNameMatch = StringMatcher.FuzzySearch(query, ExecutableNameLocalized); // var lnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableName); // var locLnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableNameLocalized); // var score = new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, executableNameMatch.Score }.Max(); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + return new[] { nameMatchScore, (descriptionMatchScore - 4) / 2, 0 }.Max(); } public static IEnumerable FilterList(IEnumerable items, string query) diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ManagedCsWin32/Shell32.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ManagedCsWin32/Shell32.cs new file mode 100644 index 0000000000..de8c8e0bf3 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ManagedCsWin32/Shell32.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace ManagedCsWin32; + +internal static partial class Shell32 +{ + [LibraryImport("SHELL32.dll", EntryPoint = "ShellExecuteExW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool ShellExecuteEx(ref SHELLEXECUTEINFOW lpExecInfo); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct SHELLEXECUTEINFOW + { + public uint CbSize; + public uint FMask; + public IntPtr Hwnd; + + public IntPtr LpVerb; + public IntPtr LpFile; + public IntPtr LpParameters; + public IntPtr LpDirectory; + public int Show; + public IntPtr HInstApp; + public IntPtr LpIDList; + public IntPtr LpClass; + public IntPtr HkeyClass; + public uint DwHotKey; + public IntPtr HIconOrMonitor; + public IntPtr Process; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs index 99db456619..ccbe6ed885 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs @@ -6,11 +6,23 @@ using System.Runtime.InteropServices; namespace Microsoft.CommandPalette.Extensions.Toolkit; -internal sealed class NativeMethods +internal static partial class NativeMethods { [DllImport("shell32.dll", CharSet = CharSet.Unicode)] internal static extern IntPtr SHGetFileInfo(string pszPath, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags); + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + internal static extern IntPtr SHGetFileInfo(IntPtr pidl, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbSizeFileInfo, uint uFlags); + + [DllImport("shell32.dll")] + internal static extern int SHParseDisplayName([MarshalAs(UnmanagedType.LPWStr)] string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut); + + [DllImport("ole32.dll")] + internal static extern void CoTaskMemFree(IntPtr pv); + + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] + internal static extern int SHLoadIndirectString(string pszSource, System.Text.StringBuilder pszOutBuf, int cchOutBuf, IntPtr ppvReserved); + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal struct SHFILEINFO { @@ -33,4 +45,58 @@ internal sealed class NativeMethods [DllImport("comctl32.dll", SetLastError = true)] internal static extern int ImageList_GetIcon(IntPtr himl, int i, int flags); + + [LibraryImport("shlwapi.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static unsafe partial int AssocQueryStringW( + AssocF flags, + AssocStr str, + string pszAssoc, + string? pszExtra, + char* pszOut, + ref uint pcchOut); + + // SHDefExtractIconW lets us ask for specific sizes (incl. 256) + // nIconSize: HIWORD = large size, LOWORD = small size + [LibraryImport("shell32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static partial int SHDefExtractIconW( + string pszIconFile, + int iIndex, + uint uFlags, + out nint phiconLarge, + out nint phiconSmall, + int nIconSize); + + [Flags] + public enum AssocF : uint + { + None = 0, + IsProtocol = 0x00001000, + } + + public enum AssocStr + { + Command = 1, + Executable, + FriendlyDocName, + FriendlyAppName, + NoOpen, + ShellNewValue, + DDECommand, + DDEIfExec, + DDEApplication, + DDETopic, + InfoTip, + QuickTip, + TileInfo, + ContentType, + DefaultIcon, + ShellExtension, + DropTarget, + DelegateExecute, + SupportedUriProtocols, + ProgId, + AppId, + AppPublisher, + AppIconReference, // sometimes present, but DefaultIcon is most common + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.txt b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.txt index c48ffb158a..21a724cd2d 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.txt +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.txt @@ -5,4 +5,7 @@ GetPackageFamilyNameFromToken CoRevertToSelf SHGetKnownFolderPath KNOWN_FOLDER_FLAG -GetCurrentPackageId \ No newline at end of file +GetCurrentPackageId + +SHOW_WINDOW_CMD +SEE_MASK_INVOKEIDLIST \ No newline at end of file diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs index 7289c704fb..e2fd310a60 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs @@ -69,6 +69,15 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit.Properties { } } + /// + /// Looks up a localized string similar to Copy failed ({0}). Please try again.. + /// + internal static string copy_failed { + get { + return ResourceManager.GetString("copy_failed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Copy path. /// @@ -105,6 +114,33 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit.Properties { } } + /// + /// Looks up a localized string similar to Open. + /// + internal static string OpenFileCommand_Name { + get { + return ResourceManager.GetString("OpenFileCommand_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open path in console. + /// + internal static string OpenInConsoleCommand_Name { + get { + return ResourceManager.GetString("OpenInConsoleCommand_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Properties. + /// + internal static string OpenPropertiesCommand_Name { + get { + return ResourceManager.GetString("OpenPropertiesCommand_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Open. /// @@ -114,6 +150,15 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit.Properties { } } + /// + /// Looks up a localized string similar to Open with. + /// + internal static string OpenWithCommand_Name { + get { + return ResourceManager.GetString("OpenWithCommand_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Settings. /// diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.resx b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.resx index 2472519d34..40fdb2c813 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.resx +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.resx @@ -141,4 +141,20 @@ Show in folder + + Open path in console + + + Properties + + + Open + + + Open with + + + Copy failed ({0}). Please try again. + {0} is the error message + \ No newline at end of file diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs index 6c761edcf2..3ddbcc5ad6 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs @@ -4,11 +4,68 @@ using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Win32; namespace Microsoft.CommandPalette.Extensions.Toolkit; public static class ShellHelpers { + /// + /// These are the executable file extensions that Windows Shell recognizes. Unlike CMD/PowerShell, + /// Shell does not use PATHEXT, but has a magic fixed list. + /// + public static string[] ExecutableExtensions { get; } = [".PIF", ".COM", ".EXE", ".BAT", ".CMD"]; + + /// + /// Determines whether the specified file name represents an executable file + /// by examining its extension against the known list of Windows Shell + /// executable extensions (a fixed list that does not honor PATHEXT). + /// + /// The file name (with or without path) whose extension will be evaluated. + /// + /// True if the file name has an extension that matches one of the recognized executable + /// extensions; otherwise, false. Returns false for null, empty, or whitespace input. + /// + public static bool IsExecutableFile(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + var fileExtension = Path.GetExtension(fileName); + return IsExecutableExtension(fileExtension); + } + + /// + /// Determines whether the provided file extension (including the leading dot) + /// is one of the Windows Shell recognized executable extensions. + /// + /// The file extension to test. Should include the leading dot (e.g. ".exe"). + /// + /// True if the extension matches (case-insensitive) one of the known executable + /// extensions; false if it does not match or if the input is null/whitespace. + /// + public static bool IsExecutableExtension(string fileExtension) + { + if (string.IsNullOrWhiteSpace(fileExtension)) + { + // Shell won't execute app with a filename without an extension + return false; + } + + foreach (var extension in ExecutableExtensions) + { + if (string.Equals(fileExtension, extension, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + public static bool OpenCommandInShell(string? path, string? pattern, string? arguments, string? workingDir = null, ShellRunAsType runAs = ShellRunAsType.None, bool runWithHiddenWindow = false) { if (string.IsNullOrEmpty(pattern)) @@ -127,7 +184,7 @@ public static class ShellHelpers var values = Environment.GetEnvironmentVariable("PATH"); if (values is not null) { - foreach (var path in values.Split(';')) + foreach (var path in values.Split(Path.PathSeparator)) { var path1 = Path.Combine(path, filename); if (File.Exists(path1)) @@ -147,13 +204,78 @@ public static class ShellHelpers token?.ThrowIfCancellationRequested(); } + } - return false; - } - else - { - return false; - } + return false; } } + + private static bool TryResolveFromAppPaths(string name, [NotNullWhen(true)] out string? fullPath) + { + try + { + fullPath = TryHiveView(RegistryHive.CurrentUser, RegistryView.Registry64) ?? + TryHiveView(RegistryHive.CurrentUser, RegistryView.Registry32) ?? + TryHiveView(RegistryHive.LocalMachine, RegistryView.Registry64) ?? + TryHiveView(RegistryHive.LocalMachine, RegistryView.Registry32) ?? string.Empty; + + return !string.IsNullOrEmpty(fullPath); + + string? TryHiveView(RegistryHive hive, RegistryView view) + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var k1 = baseKey.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{name}.exe"); + var val = (k1?.GetValue(null) as string)?.Trim('"'); + if (!string.IsNullOrEmpty(val)) + { + return val; + } + + // Some vendors create keys without .exe in the subkey name; check that too. + using var k2 = baseKey.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{name}"); + return (k2?.GetValue(null) as string)?.Trim('"'); + } + } + catch (Exception) + { + fullPath = null; + return false; + } + } + + /// + /// Mimics Windows Shell behavior to resolve an executable name to a full path. + /// + /// + /// + /// + public static bool TryResolveExecutableAsShell(string name, out string fullPath) + { + // First check if we can find the file in the registry + if (TryResolveFromAppPaths(name, out var path)) + { + fullPath = path; + return true; + } + + // If the name does not have an extension, try adding common executable extensions + // this order mimics Windows Shell behavior + // Note: HasExtension check follows Shell behavior, but differs from the + // Start Menu search results, which will offer file name with extensions + ".exe" + var nameHasExtension = Path.HasExtension(name); + if (!nameHasExtension) + { + foreach (var ext in ExecutableExtensions) + { + var nameWithExt = name + ext; + if (FileExistInPath(nameWithExt, out fullPath)) + { + return true; + } + } + } + + fullPath = string.Empty; + return false; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs deleted file mode 100644 index 6d9009661a..0000000000 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs +++ /dev/null @@ -1,311 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Globalization; - -namespace Microsoft.CommandPalette.Extensions.Toolkit; - -public partial class StringMatcher -{ - private readonly MatchOption _defaultMatchOption = new(); - - public SearchPrecisionScore UserSettingSearchPrecision { get; set; } - - // private readonly IAlphabet _alphabet; - public StringMatcher(/*IAlphabet alphabet = null*/) - { - // _alphabet = alphabet; - } - - private static StringMatcher? _instance; - - public static StringMatcher Instance - { - get - { - _instance ??= new StringMatcher(); - - return _instance; - } - set => _instance = value; - } - - private static readonly char[] Separator = new[] { ' ' }; - - public static MatchResult FuzzySearch(string query, string stringToCompare) - { - return Instance.FuzzyMatch(query, stringToCompare); - } - - public MatchResult FuzzyMatch(string query, string stringToCompare) - { - try - { - return FuzzyMatch(query, stringToCompare, _defaultMatchOption); - } - catch (IndexOutOfRangeException) - { - return new MatchResult(false, UserSettingSearchPrecision); - } - } - - /// - /// Current method: - /// Character matching + substring matching; - /// 1. Query search string is split into substrings, separator is whitespace. - /// 2. Check each query substring's characters against full compare string, - /// 3. if a character in the substring is matched, loop back to verify the previous character. - /// 4. If previous character also matches, and is the start of the substring, update list. - /// 5. Once the previous character is verified, move on to the next character in the query substring. - /// 6. Move onto the next substring's characters until all substrings are checked. - /// 7. Consider success and move onto scoring if every char or substring without whitespaces matched - /// - public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt) - { - if (string.IsNullOrEmpty(stringToCompare)) - { - return new MatchResult(false, UserSettingSearchPrecision); - } - - var bestResult = new MatchResult(false, UserSettingSearchPrecision); - - for (var startIndex = 0; startIndex < stringToCompare.Length; startIndex++) - { - MatchResult result = FuzzyMatch(query, stringToCompare, opt, startIndex); - if (result.Success && (!bestResult.Success || result.Score > bestResult.Score)) - { - bestResult = result; - } - } - - return bestResult; - } - - private MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt, int startIndex) - { - if (string.IsNullOrEmpty(stringToCompare) || string.IsNullOrEmpty(query)) - { - return new MatchResult(false, UserSettingSearchPrecision); - } - - ArgumentNullException.ThrowIfNull(opt); - - query = query.Trim(); - - // if (_alphabet is not null) - // { - // query = _alphabet.Translate(query); - // stringToCompare = _alphabet.Translate(stringToCompare); - // } - - // Using InvariantCulture since this is internal - var fullStringToCompareWithoutCase = opt.IgnoreCase ? stringToCompare.ToUpper(CultureInfo.InvariantCulture) : stringToCompare; - var queryWithoutCase = opt.IgnoreCase ? query.ToUpper(CultureInfo.InvariantCulture) : query; - - var querySubstrings = queryWithoutCase.Split(Separator, StringSplitOptions.RemoveEmptyEntries); - var currentQuerySubstringIndex = 0; - var currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; - var currentQuerySubstringCharacterIndex = 0; - - var firstMatchIndex = -1; - var firstMatchIndexInWord = -1; - var lastMatchIndex = 0; - var allQuerySubstringsMatched = false; - var matchFoundInPreviousLoop = false; - var allSubstringsContainedInCompareString = true; - - var indexList = new List(); - List spaceIndices = new List(); - - for (var compareStringIndex = startIndex; compareStringIndex < fullStringToCompareWithoutCase.Length; compareStringIndex++) - { - // To maintain a list of indices which correspond to spaces in the string to compare - // To populate the list only for the first query substring - if (fullStringToCompareWithoutCase[compareStringIndex].Equals(' ') && currentQuerySubstringIndex == 0) - { - spaceIndices.Add(compareStringIndex); - } - - bool compareResult; - if (opt.IgnoreCase) - { - var fullStringToCompare = fullStringToCompareWithoutCase[compareStringIndex].ToString(); - var querySubstring = currentQuerySubstring[currentQuerySubstringCharacterIndex].ToString(); -#pragma warning disable CA1309 // Use ordinal string comparison (We are looking for a fuzzy match here) - compareResult = string.Compare(fullStringToCompare, querySubstring, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) != 0; -#pragma warning restore CA1309 // Use ordinal string comparison - } - else - { - compareResult = fullStringToCompareWithoutCase[compareStringIndex] != currentQuerySubstring[currentQuerySubstringCharacterIndex]; - } - - if (compareResult) - { - matchFoundInPreviousLoop = false; - continue; - } - - if (firstMatchIndex < 0) - { - // first matched char will become the start of the compared string - firstMatchIndex = compareStringIndex; - } - - if (currentQuerySubstringCharacterIndex == 0) - { - // first letter of current word - matchFoundInPreviousLoop = true; - firstMatchIndexInWord = compareStringIndex; - } - else if (!matchFoundInPreviousLoop) - { - // we want to verify that there is not a better match if this is not a full word - // in order to do so we need to verify all previous chars are part of the pattern - var startIndexToVerify = compareStringIndex - currentQuerySubstringCharacterIndex; - - if (AllPreviousCharsMatched(startIndexToVerify, currentQuerySubstringCharacterIndex, fullStringToCompareWithoutCase, currentQuerySubstring)) - { - matchFoundInPreviousLoop = true; - - // if it's the beginning character of the first query substring that is matched then we need to update start index - firstMatchIndex = currentQuerySubstringIndex == 0 ? startIndexToVerify : firstMatchIndex; - - indexList = GetUpdatedIndexList(startIndexToVerify, currentQuerySubstringCharacterIndex, firstMatchIndexInWord, indexList); - } - } - - lastMatchIndex = compareStringIndex + 1; - indexList.Add(compareStringIndex); - - currentQuerySubstringCharacterIndex++; - - // if finished looping through every character in the current substring - if (currentQuerySubstringCharacterIndex == currentQuerySubstring.Length) - { - // if any of the substrings was not matched then consider as all are not matched - allSubstringsContainedInCompareString = matchFoundInPreviousLoop && allSubstringsContainedInCompareString; - - currentQuerySubstringIndex++; - - allQuerySubstringsMatched = AllQuerySubstringsMatched(currentQuerySubstringIndex, querySubstrings.Length); - if (allQuerySubstringsMatched) - { - break; - } - - // otherwise move to the next query substring - currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; - currentQuerySubstringCharacterIndex = 0; - } - } - - // proceed to calculate score if every char or substring without whitespaces matched - if (allQuerySubstringsMatched) - { - var nearestSpaceIndex = CalculateClosestSpaceIndex(spaceIndices, firstMatchIndex); - var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1, lastMatchIndex - firstMatchIndex, allSubstringsContainedInCompareString); - - return new MatchResult(true, UserSettingSearchPrecision, indexList, score); - } - - return new MatchResult(false, UserSettingSearchPrecision); - } - - // To get the index of the closest space which precedes the first matching index - private static int CalculateClosestSpaceIndex(List spaceIndices, int firstMatchIndex) - { - if (spaceIndices.Count == 0) - { - return -1; - } - else - { - return spaceIndices.OrderBy(item => (firstMatchIndex - item)).Where(item => firstMatchIndex > item).FirstOrDefault(-1); - } - } - - private static bool AllPreviousCharsMatched(int startIndexToVerify, int currentQuerySubstringCharacterIndex, string fullStringToCompareWithoutCase, string currentQuerySubstring) - { - var allMatch = true; - for (var indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) - { - if (fullStringToCompareWithoutCase[startIndexToVerify + indexToCheck] != - currentQuerySubstring[indexToCheck]) - { - allMatch = false; - } - } - - return allMatch; - } - - private static List GetUpdatedIndexList(int startIndexToVerify, int currentQuerySubstringCharacterIndex, int firstMatchIndexInWord, List indexList) - { - var updatedList = new List(); - - indexList.RemoveAll(x => x >= firstMatchIndexInWord); - - updatedList.AddRange(indexList); - - for (var indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) - { - updatedList.Add(startIndexToVerify + indexToCheck); - } - - return updatedList; - } - - private static bool AllQuerySubstringsMatched(int currentQuerySubstringIndex, int querySubstringsLength) - { - return currentQuerySubstringIndex >= querySubstringsLength; - } - - private static int CalculateSearchScore(string query, string stringToCompare, int firstIndex, int matchLen, bool allSubstringsContainedInCompareString) - { - // A match found near the beginning of a string is scored more than a match found near the end - // A match is scored more if the characters in the patterns are closer to each other, - // while the score is lower if they are more spread out - - // The length of the match is assigned a larger weight factor. - // I.e. the length is more important than the location where a match is found. - const int matchLenWeightFactor = 2; - - var score = 100 * (query.Length + 1) * matchLenWeightFactor / ((1 + firstIndex) + (matchLenWeightFactor * (matchLen + 1))); - - // A match with less characters assigning more weights - if (stringToCompare.Length - query.Length < 5) - { - score += 20; - } - else if (stringToCompare.Length - query.Length < 10) - { - score += 10; - } - - if (allSubstringsContainedInCompareString) - { - var count = query.Count(c => !char.IsWhiteSpace(c)); - var threshold = 4; - if (count <= threshold) - { - score += count * 10; - } - else - { - score += (threshold * 10) + ((count - threshold) * 5); - } - } - -#pragma warning disable CA1309 // Use ordinal string comparison (Using CurrentCultureIgnoreCase since this relates to queries input by user) - if (string.Equals(query, stringToCompare, StringComparison.CurrentCultureIgnoreCase)) - { - var bonusForExactMatch = 10; - score += bonusForExactMatch; - } -#pragma warning restore CA1309 // Use ordinal string comparison - - return score; - } -} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs index e06dbfcb22..6231f3ad72 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs @@ -2,16 +2,18 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Drawing; using System.Globalization; using System.Runtime.InteropServices; +using System.Text; using Windows.Storage; using Windows.Storage.FileProperties; using Windows.Storage.Streams; namespace Microsoft.CommandPalette.Extensions.Toolkit; -public class ThumbnailHelper +public static class ThumbnailHelper { private static readonly string[] ImageExtensions = [ @@ -24,26 +26,46 @@ public class ThumbnailHelper ".ico", ]; - public static Task GetThumbnail(string path, bool jumbo = false) + public static async Task GetThumbnail(string path, bool jumbo = false) { var extension = Path.GetExtension(path).ToLower(CultureInfo.InvariantCulture); + var isImage = ImageExtensions.Contains(extension); + if (isImage) + { + try + { + var result = await GetImageThumbnailAsync(path, jumbo); + if (result is not null) + { + return result; + } + } + catch (Exception) + { + // ignore and fall back to icon + } + } + try { - return ImageExtensions.Contains(extension) ? GetImageThumbnailAsync(path) : GetFileIconStream(path, jumbo); + return await GetFileIconStream(path, jumbo); } catch (Exception) { + // ignore and return null } - return Task.FromResult(null); + return null; } // these are windows constants and mangling them is goofy #pragma warning disable SA1310 // Field names should not contain underscore #pragma warning disable SA1306 // Field names should begin with lower-case letter private const uint SHGFI_ICON = 0x000000100; + private const uint SHGFI_LARGEICON = 0x000000000; private const uint SHGFI_SHELLICONSIZE = 0x000000004; - private const int SHGFI_SYSICONINDEX = 0x000004000; + private const uint SHGFI_SYSICONINDEX = 0x000004000; + private const uint SHGFI_PIDL = 0x000000008; private const int SHIL_JUMBO = 4; private const int ILD_TRANSPARENT = 1; #pragma warning restore SA1306 // Field names should begin with lower-case letter @@ -69,6 +91,59 @@ public class ThumbnailHelper } private static async Task GetFileIconStream(string filePath, bool jumbo) + { + return await TryExtractUsingPIDL(filePath, jumbo) + ?? await GetFileIconStreamUsingFilePath(filePath, jumbo); + } + + private static async Task TryExtractUsingPIDL(string shellPath, bool jumbo) + { + IntPtr pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, IntPtr.Zero, out pidl, 0, out _); + if (hr != 0 || pidl == IntPtr.Zero) + { + return null; + } + + nint hIcon = 0; + if (jumbo) + { + hIcon = GetLargestIcon(pidl); + } + + if (hIcon == 0) + { + var shinfo = default(NativeMethods.SHFILEINFO); + var fileInfoResult = NativeMethods.SHGetFileInfo(pidl, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_ICON | SHGFI_SHELLICONSIZE | SHGFI_LARGEICON | SHGFI_PIDL); + if (fileInfoResult != IntPtr.Zero && shinfo.hIcon != IntPtr.Zero) + { + hIcon = shinfo.hIcon; + } + } + + if (hIcon == 0) + { + return null; + } + + return await FromHIconToStream(hIcon); + } + catch (Exception) + { + return null; + } + finally + { + if (pidl != IntPtr.Zero) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } + + private static async Task GetFileIconStreamUsingFilePath(string filePath, bool jumbo) { nint hIcon = 0; @@ -99,41 +174,262 @@ public class ThumbnailHelper return null; } - var stream = new InMemoryRandomAccessStream(); - - using var memoryStream = GetMemoryStreamFromIcon(hIcon); // this will DestroyIcon hIcon - using var outputStream = stream.GetOutputStreamAt(0); - using (var dataWriter = new DataWriter(outputStream)) - { - dataWriter.WriteBytes(memoryStream.ToArray()); - await dataWriter.StoreAsync(); - await dataWriter.FlushAsync(); - } - - return stream; + return await FromHIconToStream(hIcon); } - private static async Task GetImageThumbnailAsync(string filePath) + private static async Task GetImageThumbnailAsync(string filePath, bool jumbo) { var file = await StorageFile.GetFileFromPathAsync(filePath); - var thumbnail = await file.GetThumbnailAsync(ThumbnailMode.PicturesView); + var thumbnail = await file.GetThumbnailAsync( + jumbo ? ThumbnailMode.SingleItem : ThumbnailMode.ListView, + jumbo ? 64u : 20u); return thumbnail; } + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Win32 Naming/Private")] + private static readonly Guid IID_IImageList = new Guid("46EB5926-582E-4017-9FDF-E8998DAA0950"); + private static nint GetLargestIcon(string path) { var shinfo = default(NativeMethods.SHFILEINFO); NativeMethods.SHGetFileInfo(path, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_SYSICONINDEX); var hIcon = IntPtr.Zero; - var iID_IImageList = new Guid("46EB5926-582E-4017-9FDF-E8998DAA0950"); - IntPtr imageListPtr; + var iID_IImageList = IID_IImageList; - if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out imageListPtr) == 0 && imageListPtr != IntPtr.Zero) + if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out var imageListPtr) == 0 && imageListPtr != IntPtr.Zero) { hIcon = NativeMethods.ImageList_GetIcon(imageListPtr, shinfo.iIcon, ILD_TRANSPARENT); } return hIcon; } + + private static nint GetLargestIcon(IntPtr pidl) + { + var shinfo = default(NativeMethods.SHFILEINFO); + NativeMethods.SHGetFileInfo(pidl, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_SYSICONINDEX | SHGFI_PIDL); + + var hIcon = IntPtr.Zero; + var iID_IImageList = IID_IImageList; + + if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out var imageListPtr) == 0 && imageListPtr != IntPtr.Zero) + { + hIcon = NativeMethods.ImageList_GetIcon(imageListPtr, shinfo.iIcon, ILD_TRANSPARENT); + } + + return hIcon; + } + + /// + /// Get an icon stream for a registered URI protocol (e.g. "mailto:", "http:", "steam:"). + /// + public static async Task GetProtocolIconStream(string protocol, bool jumbo) + { + // 1) Ask the shell for the protocol's default icon "path,index" + var iconRef = QueryProtocolIconReference(protocol); + if (string.IsNullOrWhiteSpace(iconRef)) + { + return null; + } + + // Indirect reference: + if (iconRef.StartsWith('@')) + { + if (TryLoadIndirectString(iconRef, out var expanded) && !string.IsNullOrWhiteSpace(expanded)) + { + iconRef = expanded; + } + } + + // 2) Handle image files from a store app + if (File.Exists(iconRef)) + { + try + { + var file = await StorageFile.GetFileFromPathAsync(iconRef); + var thumbnail = await file.GetThumbnailAsync( + jumbo ? ThumbnailMode.SingleItem : ThumbnailMode.ListView, + jumbo ? 64u : 20u); + return thumbnail; + } + catch (Exception) + { + return null; + } + } + + // 3) Parse "path,index" (index can be negative) + if (!TryParseIconReference(iconRef, out var path, out var index)) + { + return null; + } + + // if it's and .exe and without a path, let's find on path: + if (Path.GetExtension(path).Equals(".exe", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(path)) + { + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(';') ?? []; + foreach (var p in paths) + { + var candidate = Path.Combine(p, path); + if (File.Exists(candidate)) + { + path = candidate; + break; + } + } + } + + // 3) Extract an HICON, preferably ~256px when jumbo==true + var hIcon = ExtractIconHandle(path, index, jumbo); + if (hIcon == 0) + { + return null; + } + + return await FromHIconToStream(hIcon); + } + + private static bool TryLoadIndirectString(string input, out string? output) + { + var outBuffer = new StringBuilder(1024); + var hr = NativeMethods.SHLoadIndirectString(input, outBuffer, outBuffer.Capacity, IntPtr.Zero); + if (hr == 0) + { + output = outBuffer.ToString(); + return !string.IsNullOrWhiteSpace(output); + } + + output = null; + return false; + } + + private static async Task FromHIconToStream(IntPtr hIcon) + { + var stream = new InMemoryRandomAccessStream(); + + using var memoryStream = GetMemoryStreamFromIcon(hIcon); // this will DestroyIcon hIcon + using var outputStream = stream.GetOutputStreamAt(0); + using var dataWriter = new DataWriter(outputStream); + + dataWriter.WriteBytes(memoryStream.ToArray()); + await dataWriter.StoreAsync(); + await dataWriter.FlushAsync(); + + return stream; + } + + private static string? QueryProtocolIconReference(string protocol) + { + // First try DefaultIcon (most widely populated for protocols) + // If you want to try AppIconReference as a fallback, you can repeat with AssocStr.AppIconReference. + var iconReference = AssocQueryStringSafe(NativeMethods.AssocStr.DefaultIcon, protocol); + if (!string.IsNullOrWhiteSpace(iconReference)) + { + return iconReference; + } + + // Optional fallback – some registrations use AppIconReference: + iconReference = AssocQueryStringSafe(NativeMethods.AssocStr.AppIconReference, protocol); + return iconReference; + + static unsafe string? AssocQueryStringSafe(NativeMethods.AssocStr what, string protocol) + { + uint cch = 0; + + // First call: get required length (incl. null) + _ = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, null, ref cch); + if (cch == 0) + { + return null; + } + + // Small buffers on stack; large on heap + var span = cch <= 512 ? stackalloc char[(int)cch] : new char[(int)cch]; + + fixed (char* p = span) + { + var hr = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, p, ref cch); + if (hr != 0 || cch == 0) + { + return null; + } + + // cch includes the null terminator; slice it off + var len = (int)cch - 1; + if (len < 0) + { + len = 0; + } + + return new string(span.Slice(0, len)); + } + } + } + + private static bool TryParseIconReference(string iconRef, out string path, out int index) + { + // Typical shapes: + // "C:\Program Files\Outlook\OUTLOOK.EXE,-1" + // "shell32.dll,21" + // "\"C:\Some Path\app.dll\",-325" + + // If there's no comma, assume ",0" + index = 0; + path = iconRef.Trim(); + + // Split only on the last comma so paths with commas still work + var lastComma = path.LastIndexOf(','); + if (lastComma >= 0) + { + var idxPart = path[(lastComma + 1)..].Trim(); + path = path[..lastComma].Trim(); + _ = int.TryParse(idxPart, out index); + } + + // Trim quotes around path + path = path.Trim('"'); + if (path.Length > 1 && path[0] == '"' && path[^1] == '"') + { + path = path.Substring(1, path.Length - 2); + } + + // Basic sanity + return !string.IsNullOrWhiteSpace(path); + } + + private static nint ExtractIconHandle(string path, int index, bool jumbo) + { + // Request sizes: LOWORD=small, HIWORD=large. + // Ask for 256 when jumbo, else fall back to 32/16. + var small = jumbo ? 256 : 16; + var large = jumbo ? 256 : 32; + var sizeParam = (large << 16) | (small & 0xFFFF); + + var hr = NativeMethods.SHDefExtractIconW(path, index, 0, out var hLarge, out var hSmall, sizeParam); + if (hr == 0 && hLarge != 0) + { + return hLarge; + } + + if (hr == 0 && hSmall != 0) + { + return hSmall; + } + + // Final fallback: try 32/16 explicitly in case the resource can’t upscale + sizeParam = (32 << 16) | 16; + hr = NativeMethods.SHDefExtractIconW(path, index, 0, out hLarge, out hSmall, sizeParam); + if (hr == 0 && hLarge != 0) + { + return hLarge; + } + + if (hr == 0 && hSmall != 0) + { + return hSmall; + } + + return 0; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs index cdb7b72b25..87beb49075 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs @@ -26,15 +26,69 @@ public sealed class ToggleSetting : Setting public override Dictionary ToDictionary() { - return new Dictionary + var items = new List>(); + + if (!string.IsNullOrEmpty(Label)) { - { "type", "Input.Toggle" }, - { "title", Label }, - { "id", Key }, - { "label", Description }, - { "value", JsonSerializer.Serialize(Value, JsonSerializationContext.Default.Boolean) }, - { "isRequired", IsRequired }, - { "errorMessage", ErrorMessage }, + items.Add( + new() + { + { "type", "TextBlock" }, + { "text", Label }, + { "wrap", true }, + }); + } + + if (!(string.IsNullOrEmpty(Description) || string.Equals(Description, Label, StringComparison.OrdinalIgnoreCase))) + { + items.Add( + new() + { + { "type", "TextBlock" }, + { "text", Description }, + { "isSubtle", true }, + { "size", "Small" }, + { "spacing", "Small" }, + { "wrap", true }, + }); + } + + return new() + { + { "type", "ColumnSet" }, + { + "columns", new List> + { + new() + { + { "type", "Column" }, + { "width", "20px" }, + { + "items", new List> + { + new() + { + { "type", "Input.Toggle" }, + { "title", " " }, + { "id", Key }, + { "value", JsonSerializer.Serialize(Value, JsonSerializationContext.Default.Boolean) }, + { "isRequired", IsRequired }, + { "errorMessage", ErrorMessage }, + }, + } + }, + { "verticalContentAlignment", "Center" }, + }, + new() + { + { "type", "Column" }, + { "width", "stretch" }, + { "items", items }, + { "verticalContentAlignment", "Center" }, + }, + } + }, + { "spacing", "Medium" }, }; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj index 983c1594a4..a6cad871ab 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj @@ -2,7 +2,7 @@ ..\..\..\..\..\ - $(PathToRoot)packages\Microsoft.WindowsAppSDK.1.7.250513003 + $(PathToRoot)packages\Microsoft.WindowsAppSDK.1.8.250907003 $(PathToRoot)packages\Microsoft.Windows.CppWinRT.2.0.240111.5 $(PathToRoot)packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188 $(PathToRoot)packages\Microsoft.Web.WebView2.1.0.2903.40 diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config index 6a99b79e23..e945c5824d 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config @@ -1,5 +1,17 @@  + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs index 36a17ceb19..f45fc28e6a 100644 --- a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs +++ b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs @@ -10,7 +10,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; - +using ImageResizer.Properties; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Moq.Protected; @@ -101,7 +101,9 @@ namespace ImageResizer.Models private static ResizeBatch CreateBatch(Action executeAction) { var mock = new Mock { CallBase = true }; - mock.Protected().Setup("Execute", ItExpr.IsAny()).Callback(executeAction); + mock.Protected() + .Setup("Execute", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((string file, Settings settings) => executeAction(file)); return mock.Object; } diff --git a/src/modules/imageresizer/ui/ImageResizerUI.csproj b/src/modules/imageresizer/ui/ImageResizerUI.csproj index aca9f9d81e..b146db8435 100644 --- a/src/modules/imageresizer/ui/ImageResizerUI.csproj +++ b/src/modules/imageresizer/ui/ImageResizerUI.csproj @@ -24,6 +24,14 @@ Resources\ImageResizer.ico + + ImageResizerUI.dev.manifest + + + + ImageResizerUI.prod.manifest + + PublicResXFileCodeGenerator @@ -55,4 +63,14 @@ Resources.resx + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest b/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest new file mode 100644 index 0000000000..cb91bc2b66 --- /dev/null +++ b/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest @@ -0,0 +1,8 @@ + + + + + diff --git a/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest b/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest new file mode 100644 index 0000000000..bbb50a9ec5 --- /dev/null +++ b/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest @@ -0,0 +1,8 @@ + + + + + diff --git a/src/modules/imageresizer/ui/Models/ResizeBatch.cs b/src/modules/imageresizer/ui/Models/ResizeBatch.cs index 1181395c09..87e0b84e7b 100644 --- a/src/modules/imageresizer/ui/Models/ResizeBatch.cs +++ b/src/modules/imageresizer/ui/Models/ResizeBatch.cs @@ -87,9 +87,14 @@ namespace ImageResizer.Models public IEnumerable Process(Action reportProgress, CancellationToken cancellationToken) { double total = Files.Count; - var completed = 0; + int completed = 0; var errors = new ConcurrentBag(); + // NOTE: Settings.Default is captured once before parallel processing. + // Any changes to settings on disk during this batch will NOT be reflected until the next batch. + // This improves performance and predictability by avoiding repeated mutex acquisition and behaviour change results in a batch. + var settings = Settings.Default; + // TODO: If we ever switch to Windows.Graphics.Imaging, we can get a lot more throughput by using the async // APIs and a custom SynchronizationContext Parallel.ForEach( @@ -97,13 +102,12 @@ namespace ImageResizer.Models new ParallelOptions { CancellationToken = cancellationToken, - MaxDegreeOfParallelism = Environment.ProcessorCount, }, (file, state, i) => { try { - Execute(file); + Execute(file, settings); } catch (Exception ex) { @@ -111,14 +115,13 @@ namespace ImageResizer.Models } Interlocked.Increment(ref completed); - reportProgress(completed, total); }); return errors; } - protected virtual void Execute(string file) - => new ResizeOperation(file, DestinationDirectory, Settings.Default).Execute(); + protected virtual void Execute(string file, Settings settings) + => new ResizeOperation(file, DestinationDirectory, settings).Execute(); } } diff --git a/src/modules/imageresizer/ui/Properties/Settings.cs b/src/modules/imageresizer/ui/Properties/Settings.cs index debb26a191..0f8690dcbb 100644 --- a/src/modules/imageresizer/ui/Properties/Settings.cs +++ b/src/modules/imageresizer/ui/Properties/Settings.cs @@ -461,33 +461,42 @@ namespace ImageResizer.Properties { } - // Needs to be called on the App UI thread as the properties are bound to the UI. - App.Current.Dispatcher.Invoke(() => + if (App.Current?.Dispatcher != null) { - ShrinkOnly = jsonSettings.ShrinkOnly; - Replace = jsonSettings.Replace; - IgnoreOrientation = jsonSettings.IgnoreOrientation; - RemoveMetadata = jsonSettings.RemoveMetadata; - JpegQualityLevel = jsonSettings.JpegQualityLevel; - PngInterlaceOption = jsonSettings.PngInterlaceOption; - TiffCompressOption = jsonSettings.TiffCompressOption; - FileName = jsonSettings.FileName; - KeepDateModified = jsonSettings.KeepDateModified; - FallbackEncoder = jsonSettings.FallbackEncoder; - CustomSize = jsonSettings.CustomSize; - SelectedSizeIndex = jsonSettings.SelectedSizeIndex; - - if (jsonSettings.Sizes.Count > 0) - { - Sizes.Clear(); - Sizes.AddRange(jsonSettings.Sizes); - - // Ensure Ids are unique and handle missing Ids - IdRecoveryHelper.RecoverInvalidIds(Sizes); - } - }); + // Needs to be called on the App UI thread as the properties are bound to the UI. + App.Current.Dispatcher.Invoke(() => ReloadCore(jsonSettings)); + } + else + { + ReloadCore(jsonSettings); + } _jsonMutex.ReleaseMutex(); } + + private void ReloadCore(Settings jsonSettings) + { + ShrinkOnly = jsonSettings.ShrinkOnly; + Replace = jsonSettings.Replace; + IgnoreOrientation = jsonSettings.IgnoreOrientation; + RemoveMetadata = jsonSettings.RemoveMetadata; + JpegQualityLevel = jsonSettings.JpegQualityLevel; + PngInterlaceOption = jsonSettings.PngInterlaceOption; + TiffCompressOption = jsonSettings.TiffCompressOption; + FileName = jsonSettings.FileName; + KeepDateModified = jsonSettings.KeepDateModified; + FallbackEncoder = jsonSettings.FallbackEncoder; + CustomSize = jsonSettings.CustomSize; + SelectedSizeIndex = jsonSettings.SelectedSizeIndex; + + if (jsonSettings.Sizes.Count > 0) + { + Sizes.Clear(); + Sizes.AddRange(jsonSettings.Sizes); + + // Ensure Ids are unique and handle missing Ids + IdRecoveryHelper.RecoverInvalidIds(Sizes); + } + } } } diff --git a/src/modules/keyboardmanager/dll/KeyboardManager.vcxproj b/src/modules/keyboardmanager/dll/KeyboardManager.vcxproj index 255ded7abd..34e37eafb2 100644 --- a/src/modules/keyboardmanager/dll/KeyboardManager.vcxproj +++ b/src/modules/keyboardmanager/dll/KeyboardManager.vcxproj @@ -48,7 +48,6 @@ - Create @@ -66,6 +65,7 @@ + @@ -82,4 +82,7 @@ + + + \ No newline at end of file diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png index 4228da1e88..7a96d92df1 100644 Binary files a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png and b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png differ diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png index 6c4dcc5ae5..580cf3f609 100644 Binary files a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png and b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png differ diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs index d08ec588d3..a6cec9880d 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs @@ -74,7 +74,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.UnitTests var result = main.Object.Query(expectedQuery).FirstOrDefault().SubTitle; // Assert - Assert.AreEqual("Reboot computer into UEFI Firmware Settings (Requires administrative permissions.)", result); + Assert.AreEqual("Reboot computer into UEFI firmware settings (Requires administrative permissions.)", result); } [TestMethod] diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs index 5d07afd41f..a530fbc580 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -682,7 +682,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { } /// - /// Looks up a localized string similar to You are about to reboot this computer into UEFI Firmware Settings menu, are you sure?. + /// Looks up a localized string similar to You are about to reboot this computer into UEFI firmware settings menu, are you sure?. /// internal static string Microsoft_plugin_sys_uefi_confirmation { get { @@ -691,7 +691,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { } /// - /// Looks up a localized string similar to Reboot computer into UEFI Firmware Settings (Requires administrative permissions.). + /// Looks up a localized string similar to Reboot computer into UEFI firmware settings (Requires administrative permissions.). /// internal static string Microsoft_plugin_sys_uefi_description { get { diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx index eeaa8a423b..f9d1deaab2 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx @@ -362,15 +362,15 @@ Means type like category. Here it means network interface type (ethernet, wifi, ...). - UEFI Firmware Settings + UEFI firmware settings This should align to the action in Windows Recovery Environment that restart into uefi settings. - You are about to reboot this computer into UEFI Firmware Settings menu, are you sure? + You are about to reboot this computer into UEFI firmware settings menu, are you sure? This should align to the action in Windows Recovery Environment that restart into uefi settings. - Reboot computer into UEFI Firmware Settings (Requires administrative permissions.) + Reboot computer into UEFI firmware settings (Requires administrative permissions.) This should align to the action in Windows Recovery Environment that restart into uefi settings. diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx index e1e3a68766..84d81c1b96 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx @@ -208,7 +208,7 @@ 'UTC' means here 'Universal Time Convention' - Provides time and date values for the system time or a custom time stamp (e.g.'{0}', '{1}', '{2}', '{3}') + Shows time and date values for the system time or a custom time stamp (e.g.'{0}', '{1}', '{2}', '{3}') Do not translate the placeholders like '{0}' because it will be replaced in code. diff --git a/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs b/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs index 5003a02cae..4ff1a08697 100644 --- a/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs +++ b/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs @@ -61,7 +61,7 @@ namespace PowerLauncher.Helper // Many bug reports because users see the "Report problem UI" after "the" crash with System.Runtime.InteropServices.COMException 0xD0000701 or 0x80263001. // However, displaying this "Report problem UI" during WPF crashes, especially when DWM composition is changing, is not ideal; some users reported it hangs for up to a minute before the "Report problem UI" appears. // This change modifies the behavior to log the exception instead of showing the "Report problem UI". - if (IsDwmCompositionException(e as System.Runtime.InteropServices.COMException)) + if (ExceptionHelper.IsRecoverableDwmCompositionException(e as System.Runtime.InteropServices.COMException)) { var logger = LogManager.GetLogger(LoggerName); logger.Error($"From {(isNotUIThread ? "non" : string.Empty)} UI thread's exception: {ExceptionFormatter.FormatException(e)}"); @@ -91,22 +91,5 @@ namespace PowerLauncher.Helper } } } - - private static bool IsDwmCompositionException(System.Runtime.InteropServices.COMException comException) - { - if (comException == null) - { - return false; - } - - var stackTrace = comException.StackTrace; - if (string.IsNullOrEmpty(stackTrace)) - { - return false; - } - - // Check for common DWM composition changed patterns in the stack trace - return stackTrace.Contains("DwmCompositionChanged"); - } } } diff --git a/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs b/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs new file mode 100644 index 0000000000..15e7de4eac --- /dev/null +++ b/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs @@ -0,0 +1,46 @@ +// 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; + +namespace PowerLauncher.Helper +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Win32 naming conventions")] + internal static class ExceptionHelper + { + private const string PresentationFrameworkExceptionSource = "PresentationFramework"; + + private const int DWM_E_COMPOSITIONDISABLED = unchecked((int)0x80263001); + + // HRESULT for NT STATUS STATUS_MESSAGE_LOST (0xC0000701 | 0x10000000 == 0xD0000701) + private const int STATUS_MESSAGE_LOST_HR = unchecked((int)0xD0000701); + + /// + /// Returns true if the exception is a recoverable DWM composition exception. + /// + internal static bool IsRecoverableDwmCompositionException(Exception exception) + { + if (exception is not COMException comException) + { + return false; + } + + if (comException.HResult is DWM_E_COMPOSITIONDISABLED) + { + return true; + } + + if (comException.HResult is STATUS_MESSAGE_LOST_HR && comException.Source == PresentationFrameworkExceptionSource) + { + return true; + } + + // Check for common DWM composition changed patterns in the stack trace + var stackTrace = comException.StackTrace; + return !string.IsNullOrEmpty(stackTrace) && + stackTrace.Contains("DwmCompositionChanged"); + } + } +} diff --git a/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs b/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs index 2a27494b30..53cc841b30 100644 --- a/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs +++ b/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs @@ -3,13 +3,16 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Media; using ManagedCommon; using Microsoft.Win32; using Wox.Infrastructure.Image; using Wox.Infrastructure.UserSettings; +using Wox.Plugin.Logger; namespace PowerLauncher.Helper { @@ -20,6 +23,9 @@ namespace PowerLauncher.Helper private readonly ThemeHelper _themeHelper = new(); private bool _disposed; + private CancellationTokenSource _themeUpdateTokenSource; + private const int MaxRetries = 5; + private const int InitialDelayMs = 2000; public Theme CurrentTheme { get; private set; } @@ -108,10 +114,80 @@ namespace PowerLauncher.Helper { Theme newTheme = _themeHelper.DetermineTheme(_settings.Theme); - _mainWindow.Dispatcher.Invoke(() => + // Cancel any existing theme update operation + _themeUpdateTokenSource?.Cancel(); + _themeUpdateTokenSource?.Dispose(); + _themeUpdateTokenSource = new CancellationTokenSource(); + + // Start theme update with retry logic in the background + _ = UpdateThemeWithRetryAsync(newTheme, _themeUpdateTokenSource.Token); + } + + /// + /// Applies the theme with retry logic for desktop composition errors. + /// + /// The theme to apply. + /// Token to cancel the operation. + private async Task UpdateThemeWithRetryAsync(Theme theme, CancellationToken cancellationToken) + { + var delayMs = 0; + const int maxAttempts = MaxRetries + 1; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { - SetSystemTheme(newTheme); - }); + try + { + if (delayMs > 0) + { + await Task.Delay(delayMs, cancellationToken); + } + + if (cancellationToken.IsCancellationRequested) + { + Log.Debug("Theme update operation was cancelled.", typeof(ThemeManager)); + return; + } + + await _mainWindow.Dispatcher.InvokeAsync(() => + { + SetSystemTheme(theme); + }); + + if (attempt > 1) + { + Log.Info($"Successfully applied theme after {attempt - 1} retry attempt(s).", typeof(ThemeManager)); + } + + return; + } + catch (COMException ex) when (ExceptionHelper.IsRecoverableDwmCompositionException(ex)) + { + switch (attempt) + { + case 1: + Log.Warn($"Desktop composition is disabled (HRESULT: 0x{ex.HResult:X}). Scheduling retries for theme update.", typeof(ThemeManager)); + delayMs = InitialDelayMs; + break; + case < maxAttempts: + Log.Warn($"Retry {attempt - 1}/{MaxRetries} failed: Desktop composition still disabled. Retrying in {delayMs * 2}ms...", typeof(ThemeManager)); + delayMs *= 2; + break; + default: + Log.Exception($"Failed to set theme after {MaxRetries} retry attempts. Desktop composition remains disabled.", ex, typeof(ThemeManager)); + break; + } + } + catch (OperationCanceledException) + { + Log.Debug("Theme update operation was cancelled.", typeof(ThemeManager)); + return; + } + catch (Exception ex) + { + Log.Exception($"Unexpected error during theme update (attempt {attempt}/{maxAttempts}): {ex.Message}", ex, typeof(ThemeManager)); + throw; + } + } } public void Dispose() @@ -130,6 +206,8 @@ namespace PowerLauncher.Helper if (disposing) { SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; + _themeUpdateTokenSource?.Cancel(); + _themeUpdateTokenSource?.Dispose(); } _disposed = true; diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs index 586b92ed75..a3721a04ec 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs @@ -24,13 +24,15 @@ using Windows.Storage; namespace Peek.FilePreviewer.Previewers.MediaPreviewer { - public partial class AudioPreviewer : ObservableObject, IAudioPreviewer + public partial class AudioPreviewer : ObservableObject, IDisposable, IAudioPreviewer { + private MediaSource? _mediaSource; + [ObservableProperty] private PreviewState _state; [ObservableProperty] - private AudioPreviewData _preview; + private AudioPreviewData? _preview; private IFileSystemItem Item { get; } @@ -40,7 +42,6 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { Item = file; Dispatcher = DispatcherQueue.GetForCurrentThread(); - Preview = new AudioPreviewData(); } public async Task CopyAsync() @@ -63,19 +64,23 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { State = PreviewState.Loading; + Preview = new AudioPreviewData(); + var thumbnailTask = LoadThumbnailAsync(cancellationToken); var sourceTask = LoadSourceAsync(cancellationToken); var metadataTask = LoadMetadataAsync(cancellationToken); await Task.WhenAll(thumbnailTask, sourceTask, metadataTask); - if (!thumbnailTask.Result || !sourceTask.Result || !metadataTask.Result) + if (sourceTask.Result && metadataTask.Result) { - State = PreviewState.Error; + State = PreviewState.Loaded; } else { - State = PreviewState.Loaded; + // Release all resources on error. + Unload(); + State = PreviewState.Error; } } @@ -88,12 +93,15 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { cancellationToken.ThrowIfCancellationRequested(); - var thumbnail = await ThumbnailHelper.GetThumbnailAsync(Item.Path, cancellationToken) - ?? await ThumbnailHelper.GetIconAsync(Item.Path, cancellationToken); + if (Preview != null) + { + var thumbnail = await ThumbnailHelper.GetThumbnailAsync(Item.Path, cancellationToken) + ?? await ThumbnailHelper.GetIconAsync(Item.Path, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - Preview.Thumbnail = thumbnail ?? new SvgImageSource(new Uri("ms-appx:///Assets/Peek/DefaultFileIcon.svg")); + Preview.Thumbnail = thumbnail ?? new SvgImageSource(new Uri("ms-appx:///Assets/Peek/DefaultFileIcon.svg")); + } }); }); } @@ -110,7 +118,11 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { cancellationToken.ThrowIfCancellationRequested(); - Preview.MediaSource = MediaSource.CreateFromStorageFile(storageFile); + if (Preview != null) + { + _mediaSource = MediaSource.CreateFromStorageFile(storageFile); + Preview.MediaSource = _mediaSource; + } }); }); } @@ -123,6 +135,11 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer await Dispatcher.RunOnUiThread(() => { + if (Preview == null) + { + return; + } + cancellationToken.ThrowIfCancellationRequested(); Preview.Title = PropertyStoreHelper.TryGetStringProperty(Item.Path, PropertyKey.MusicTitle) ?? Item.Name[..^Item.Extension.Length]; @@ -160,6 +177,22 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer return _supportedFileTypes.Contains(item.Extension); } + public void Dispose() + { + Unload(); + GC.SuppressFinalize(this); + } + + /// + /// Explicitly unloads the preview and releases file resources. + /// + public void Unload() + { + _mediaSource?.Dispose(); + _mediaSource = null; + Preview = null; + } + private static readonly HashSet _supportedFileTypes = new() { ".aac", diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs index b9de53e87b..061d3eca47 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs @@ -25,6 +25,8 @@ namespace Peek.FilePreviewer.Previewers { public partial class VideoPreviewer : ObservableObject, IVideoPreviewer, IDisposable { + private MediaSource? _mediaSource; + [ObservableProperty] private MediaSource? preview; @@ -56,6 +58,7 @@ namespace Peek.FilePreviewer.Previewers public void Dispose() { + Unload(); GC.SuppressFinalize(this); } @@ -145,7 +148,8 @@ namespace Peek.FilePreviewer.Previewers MissingCodecName = missingCodecName; } - Preview = MediaSource.CreateFromStorageFile(storageFile); + _mediaSource = MediaSource.CreateFromStorageFile(storageFile); + Preview = _mediaSource; }); }); } @@ -155,6 +159,16 @@ namespace Peek.FilePreviewer.Previewers return !(VideoTask?.Result ?? true); } + /// + /// Explicitly unloads the preview and releases file resources. + /// + public void Unload() + { + _mediaSource?.Dispose(); + _mediaSource = null; + Preview = null; + } + private static readonly HashSet _supportedFileTypes = new() { ".mp4", ".3g2", ".3gp", ".3gp2", ".3gpp", ".asf", ".avi", ".m2t", ".m2ts", diff --git a/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs b/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs index fb57e80047..89e814b6f4 100644 --- a/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs +++ b/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs @@ -115,19 +115,55 @@ namespace Peek.UI.Helpers } /// - /// Returns whether the caret is visible in the specified window. + /// Heuristic to decide whether the user is actively typing so we should suppress Peek activation. + /// Current logic: + /// - If the focused control class name contains "Edit" or "Input" (e.g. Explorer search box or in-place rename), return true. + /// - Otherwise fall back to the legacy GUI_CARETBLINKING flag (covers other text contexts where class name differs but caret blinks). + /// - If we fail to retrieve GUI thread info, we default to false (do not suppress) to avoid blocking activation due to transient failures. + /// NOTE: This intentionally no longer walks ancestor chains; any Edit/Input focus inside the same top-level Explorer/Desktop window is treated as typing. /// - private static bool CaretVisible(HWND hwnd) + private static unsafe bool CaretVisible(HWND hwnd) { - GUITHREADINFO guiThreadInfo = new() { cbSize = (uint)Marshal.SizeOf() }; - - // Get information for the foreground thread - if (PInvoke_PeekUI.GetGUIThreadInfo(0, ref guiThreadInfo)) + GUITHREADINFO gi = new() { cbSize = (uint)Marshal.SizeOf() }; + if (!PInvoke_PeekUI.GetGUIThreadInfo(0, ref gi)) { - return guiThreadInfo.hwndActive == hwnd && (guiThreadInfo.flags & GUITHREADINFO_FLAGS.GUI_CARETBLINKING) != 0; + return false; // fail open (allow activation) } - return false; + // Quick sanity: restrict to same top-level window (match prior behavior) + if (gi.hwndActive != hwnd) + { + return false; + } + + HWND focus = gi.hwndFocus; + if (focus == HWND.Null) + { + return false; + } + + // Get focused window class (96 chars buffer; GetClassNameW bounds writes). Treat any class containing + // "Edit" or "Input" as a text field (search / titlebar) and suppress Peek. + Span buf = stackalloc char[96]; + fixed (char* p = buf) + { + int len = PInvoke_PeekUI.GetClassName(focus, p, buf.Length); + if (len > 0) + { + var focusClass = new string(p, 0, len); + if (focusClass.Contains("Edit", StringComparison.OrdinalIgnoreCase) || focusClass.Contains("Input", StringComparison.OrdinalIgnoreCase)) + { + return true; // treat any Edit/Input focus as typing. + } + else + { + ManagedCommon.Logger.LogDebug($"Peek suppression: focus class{focusClass}"); + } + } + } + + // Fallback: original caret blinking heuristic for other text-entry contexts + return (gi.flags & GUITHREADINFO_FLAGS.GUI_CARETBLINKING) != 0; } } } diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index db54e346cc..775664c042 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -62,6 +62,12 @@ namespace Peek.UI [ObservableProperty] private IFileSystemItem? _currentItem; + /// + /// Work around missing navigation when peeking from CLI. + /// TODO: Implement navigation when peeking from CLI. + /// + private bool _isFromCli; + partial void OnCurrentItemChanged(IFileSystemItem? value) { WindowTitle = value != null @@ -129,7 +135,24 @@ namespace Peek.UI NavigationThrottleTimer.Interval = TimeSpan.FromMilliseconds(NavigationThrottleDelayMs); } - public void Initialize(HWND foregroundWindowHandle) + public void Initialize(SelectedItem selectedItem) + { + switch (selectedItem) + { + case SelectedItemByPath selectedItemByPath: + InitializeFromCli(selectedItemByPath.Path); + break; + + case SelectedItemByWindowHandle selectedItemByWindowHandle: + InitializeFromExplorer(selectedItemByWindowHandle.WindowHandle); + break; + + default: + throw new NotImplementedException($"Invalid type of selected item: '{selectedItem.GetType().FullName}'"); + } + } + + private void InitializeFromExplorer(HWND foregroundWindowHandle) { try { @@ -141,10 +164,20 @@ namespace Peek.UI } _currentIndex = DisplayIndex = 0; + _isFromCli = false; CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null; } + private void InitializeFromCli(string path) + { + // TODO: implement navigation + _isFromCli = true; + Items = null; + _currentIndex = DisplayIndex = 0; + CurrentItem = new FileItem(path, Path.GetFileName(path)); + } + public void Uninitialize() { _currentIndex = DisplayIndex = 0; @@ -153,6 +186,7 @@ namespace Peek.UI Items = null; _navigationDirection = NavigationDirection.Forwards; IsErrorVisible = false; + _isFromCli = false; } public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards); @@ -166,6 +200,12 @@ namespace Peek.UI return; } + // TODO: implement navigation. + if (_isFromCli) + { + return; + } + if (Items == null || Items.Count == _deletedItemIndexes.Count) { _currentIndex = DisplayIndex = 0; diff --git a/src/modules/peek/Peek.UI/Models/SelectedItem.cs b/src/modules/peek/Peek.UI/Models/SelectedItem.cs new file mode 100644 index 0000000000..9d0cc6568a --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItem.cs @@ -0,0 +1,11 @@ +// 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 Peek.UI.Models +{ + public abstract class SelectedItem + { + public abstract bool Matches(string? path); + } +} diff --git a/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs b/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs new file mode 100644 index 0000000000..5f53865bfd --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs @@ -0,0 +1,23 @@ +// 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 Peek.UI.Models +{ + public class SelectedItemByPath : SelectedItem + { + public string Path { get; } + + public SelectedItemByPath(string path) + { + Path = path; + } + + public override bool Matches(string? path) + { + return string.Equals(Path, path, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs b/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs new file mode 100644 index 0000000000..e93b2f94ca --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs @@ -0,0 +1,34 @@ +// 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 Peek.UI.Extensions; +using Peek.UI.Helpers; +using Windows.Win32.Foundation; + +namespace Peek.UI.Models +{ + public class SelectedItemByWindowHandle : SelectedItem + { + public HWND WindowHandle { get; } + + public SelectedItemByWindowHandle(HWND windowHandle) + { + WindowHandle = windowHandle; + } + + public override bool Matches(string? path) + { + var selectedItems = FileExplorerHelper.GetSelectedItems(WindowHandle); + var selectedItemsCount = selectedItems?.GetCount() ?? 0; + if (selectedItems == null || selectedItemsCount == 0 || selectedItemsCount > 1) + { + return false; + } + + var fileExplorerSelectedItemPath = selectedItems.GetItemAt(0).ToIFileSystemItem().Path; + var currentItemPath = path; + return fileExplorerSelectedItemPath != null && currentItemPath != null && fileExplorerSelectedItemPath != currentItemPath; + } + } +} diff --git a/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs index 9bd66e380f..b89e871a4d 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs @@ -3,16 +3,19 @@ // See the LICENSE file in the project root for more information. using System; - +using System.IO; +using System.Threading; using ManagedCommon; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls.Primitives; using Peek.Common; using Peek.FilePreviewer; using Peek.FilePreviewer.Models; using Peek.FilePreviewer.Previewers; +using Peek.UI.Models; using Peek.UI.Native; using Peek.UI.Telemetry.Events; using Peek.UI.Views; @@ -23,7 +26,7 @@ namespace Peek.UI /// /// Provides application-specific behavior to supplement the default Application class. /// - public partial class App : Application, IApp + public partial class App : Application, IApp, IDisposable { public static int PowerToysPID { get; set; } @@ -36,6 +39,10 @@ namespace Peek.UI private MainWindow? Window { get; set; } + private bool _disposed; + private SelectedItem? _selectedItem; + private bool _launchedFromCli; + /// /// Initializes a new instance of the class. /// Initializes the singleton application object. This is the first line of authored code @@ -52,22 +59,22 @@ namespace Peek.UI InitializeComponent(); Logger.InitializeLogger("\\Peek\\Logs"); - Host = Microsoft.Extensions.Hosting.Host. - CreateDefaultBuilder(). - UseContentRoot(AppContext.BaseDirectory). - ConfigureServices((context, services) => - { - // Core Services - services.AddTransient(); - services.AddSingleton(); - services.AddSingleton(); + Host = Microsoft.Extensions.Hosting.Host + .CreateDefaultBuilder() + .UseContentRoot(AppContext.BaseDirectory) + .ConfigureServices((context, services) => + { + // Core Services + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); - // Views and ViewModels - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - }). - Build(); + // Views and ViewModels + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + }) + .Build(); UnhandledException += App_UnhandledException; } @@ -99,6 +106,7 @@ namespace Peek.UI var cmdArgs = Environment.GetCommandLineArgs(); if (cmdArgs?.Length > 1) { + // Check if the last argument is a PowerToys Runner PID if (int.TryParse(cmdArgs[^1], out int powerToysRunnerPid)) { RunnerHelper.WaitForPowerToysRunner(powerToysRunnerPid, () => @@ -107,9 +115,25 @@ namespace Peek.UI Environment.Exit(0); }); } + else + { + // Command line argument is a file path - activate Peek with that file + string filePath = cmdArgs[^1]; + if (File.Exists(filePath) || Directory.Exists(filePath)) + { + _selectedItem = new SelectedItemByPath(filePath); + _launchedFromCli = true; + OnShowPeek(); + return; + } + else + { + Logger.LogError($"Command line argument is not a valid file or directory: {filePath}"); + } + } } - NativeEventWaiter.WaitForEventLoop(Constants.ShowPeekEvent(), OnPeekHotkey); + NativeEventWaiter.WaitForEventLoop(Constants.ShowPeekEvent(), OnShowPeek); NativeEventWaiter.WaitForEventLoop(Constants.TerminatePeekEvent(), () => { ShellPreviewHandlerPreviewer.ReleaseHandlerFactories(); @@ -126,11 +150,16 @@ namespace Peek.UI /// /// Handle Peek hotkey /// - private void OnPeekHotkey() + private void OnShowPeek() { - // Need to read the foreground HWND before activating Peek to avoid focus stealing - // Foreground HWND must always be Explorer or Desktop - var foregroundWindowHandle = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow(); + // null means explorer, not null means CLI + if (_selectedItem == null) + { + // Need to read the foreground HWND before activating Peek to avoid focus stealing + // Foreground HWND must always be Explorer or Desktop + var foregroundWindowHandle = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow(); + _selectedItem = new SelectedItemByWindowHandle(foregroundWindowHandle); + } bool firstActivation = false; @@ -140,7 +169,38 @@ namespace Peek.UI Window = new MainWindow(); } - Window.Toggle(firstActivation, foregroundWindowHandle); + Window.Toggle(firstActivation, _selectedItem, _launchedFromCli); + _launchedFromCli = false; + _selectedItem = null; + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // dispose managed state (managed objects) + } + + // free unmanaged resources (unmanaged objects) and override finalizer + // set large fields to null + _disposed = true; + } + } + + /* // override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + // ~App() + // { + // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + // Dispose(disposing: false); + // } */ + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); } } } diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index 4edad9a807..2c8983c634 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -18,6 +18,7 @@ using Peek.FilePreviewer.Models; using Peek.FilePreviewer.Previewers; using Peek.UI.Extensions; using Peek.UI.Helpers; +using Peek.UI.Models; using Peek.UI.Telemetry.Events; using Windows.Foundation; using WinUIEx; @@ -38,6 +39,7 @@ namespace Peek.UI /// dialog is open at a time. /// private bool _isDeleteInProgress; + private bool _exitAfterClose; public MainWindow() { @@ -116,12 +118,17 @@ namespace Peek.UI /// /// Toggling the window visibility and querying files when necessary. /// - public void Toggle(bool firstActivation, Windows.Win32.Foundation.HWND foregroundWindowHandle) + public void Toggle(bool firstActivation, SelectedItem selectedItem, bool exitAfterClose) { + if (exitAfterClose) + { + _exitAfterClose = true; + } + if (firstActivation) { Activate(); - Initialize(foregroundWindowHandle); + Initialize(selectedItem); return; } @@ -132,9 +139,9 @@ namespace Peek.UI if (AppWindow.IsVisible) { - if (IsNewSingleSelectedItem(foregroundWindowHandle)) + if (IsNewSingleSelectedItem(selectedItem)) { - Initialize(foregroundWindowHandle); + Initialize(selectedItem); Activate(); // Brings existing window into focus in case it was previously minimized } else @@ -144,7 +151,7 @@ namespace Peek.UI } else { - Initialize(foregroundWindowHandle); + Initialize(selectedItem); } } @@ -182,12 +189,12 @@ namespace Peek.UI Uninitialize(); } - private void Initialize(Windows.Win32.Foundation.HWND foregroundWindowHandle) + private void Initialize(SelectedItem selectedItem) { var bootTime = new System.Diagnostics.Stopwatch(); bootTime.Start(); - ViewModel.Initialize(foregroundWindowHandle); + ViewModel.Initialize(selectedItem); ViewModel.ScalingFactor = this.GetMonitorScale(); this.Content.KeyUp += Content_KeyUp; @@ -207,6 +214,11 @@ namespace Peek.UI this.Content.KeyUp -= Content_KeyUp; ShellPreviewHandlerPreviewer.ReleaseHandlerFactories(); + + if (_exitAfterClose) + { + Environment.Exit(0); + } } /// @@ -272,20 +284,11 @@ namespace Peek.UI Uninitialize(); } - private bool IsNewSingleSelectedItem(Windows.Win32.Foundation.HWND foregroundWindowHandle) + private bool IsNewSingleSelectedItem(SelectedItem selectedItem) { try { - var selectedItems = FileExplorerHelper.GetSelectedItems(foregroundWindowHandle); - var selectedItemsCount = selectedItems?.GetCount() ?? 0; - if (selectedItems == null || selectedItemsCount == 0 || selectedItemsCount > 1) - { - return false; - } - - var fileExplorerSelectedItemPath = selectedItems.GetItemAt(0).ToIFileSystemItem().Path; - var currentItemPath = ViewModel.CurrentItem?.Path; - return fileExplorerSelectedItemPath != null && currentItemPath != null && fileExplorerSelectedItemPath != currentItemPath; + return selectedItem.Matches(ViewModel.CurrentItem?.Path); } catch (Exception ex) { diff --git a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml index 57e073d8a5..c33a9d9d7d 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml @@ -93,6 +93,7 @@ Grid.Column="4" VerticalAlignment="Center" Command="{x:Bind PinCommand, Mode=OneWay}" + Style="{StaticResource SubtleButtonStyle}" ToolTipService.ToolTip="{x:Bind PinToolTip(Pinned), Mode=OneWay}"> + { + // Get or ensure properties section exists + Dictionary properties; + + if (settings.TryGetValue("properties", out var propertiesObj)) + { + if (propertiesObj is Dictionary dict) + { + properties = dict; + } + else if (propertiesObj is JsonElement jsonElem) + { + properties = JsonSerializer.Deserialize>(jsonElem.GetRawText()) + ?? throw new InvalidOperationException("Failed to deserialize properties"); + } + else + { + properties = new Dictionary(); + } + } + else + { + properties = new Dictionary(); + } + + // Update the required properties + properties["ActivationShortcut"] = new Dictionary + { + { "win", false }, + { "ctrl", true }, + { "alt", false }, + { "shift", false }, + { "code", 32 }, + { "key", "Space" }, + }; + + properties["EnableSpaceToActivate"] = new Dictionary + { + { "value", false }, + }; + + settings["properties"] = properties; + }); + + // Disable all modules except Peek in global settings + SettingsConfigHelper.ConfigureGlobalModuleSettings("Peek"); + + Debug.WriteLine("Successfully updated all settings - Peek shortcut configured and all modules except Peek disabled"); + } + catch (Exception ex) + { + Assert.Fail($"ERROR in FixSettingsFileBeforeTests: {ex.Message}"); + } + } + [TestInitialize] public void TestInitialize() { diff --git a/src/modules/peek/peek/dllmain.cpp b/src/modules/peek/peek/dllmain.cpp index 4c3da5d999..1127df38bd 100644 --- a/src/modules/peek/peek/dllmain.cpp +++ b/src/modules/peek/peek/dllmain.cpp @@ -1,15 +1,17 @@ #include "pch.h" -#include +#include "trace.h" +#include +#include +#include +#include #include #include -#include "trace.h" -#include -#include -#include -#include -#include -#include #include +#include +#include +#include +#include +#include extern "C" IMAGE_DOS_HEADER __ImageBase; @@ -32,6 +34,9 @@ BOOL APIENTRY DllMain(HMODULE /*hModule*/, return TRUE; } +// Forward declare global Peek so anonymous namespace uses same type +class Peek; + namespace { const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; @@ -42,6 +47,17 @@ namespace const wchar_t JSON_KEY_CODE[] = L"code"; const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut"; const wchar_t JSON_KEY_ALWAYS_RUN_NOT_ELEVATED[] = L"AlwaysRunNotElevated"; + const wchar_t JSON_KEY_ENABLE_SPACE_TO_ACTIVATE[] = L"EnableSpaceToActivate"; + + // Space activation (single-space mode) state + std::atomic_bool g_foregroundHookActive{ false }; // Foreground hook installed + std::atomic_bool g_foregroundEligible{ false }; // Cached eligibility (Explorer/Desktop/Peek focused) + HWINEVENTHOOK g_foregroundHook = nullptr; // Foreground change hook handle + constexpr DWORD FOREGROUND_DEBOUNCE_MS = 40; // Delay before eligibility recompute (ms) + HANDLE g_foregroundDebounceTimer = nullptr; // One-shot scheduled timer + std::atomic g_foregroundLastScheduleTick{ 0 }; // Tick count when timer last scheduled + + Peek* g_instance = nullptr; // pointer to active instance (global Peek) } // The PowerToy name that will be shown in the settings. @@ -60,6 +76,7 @@ private: // If we should always try to run Peek non-elevated. bool m_alwaysRunNotElevated = true; + bool m_enableSpaceToActivate = false; // toggle from settings HANDLE m_hProcess = 0; DWORD m_processPid = 0; @@ -111,11 +128,55 @@ private: m_alwaysRunNotElevated = true; } + try + { + auto jsonEnableSpaceObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ENABLE_SPACE_TO_ACTIVATE); + m_enableSpaceToActivate = jsonEnableSpaceObject.GetNamedBoolean(L"value"); + } + catch (...) + { + m_enableSpaceToActivate = false; + } + + // Enforce design: if space toggle ON, force single-space hotkey and store previous combination once. + if (m_enableSpaceToActivate) + { + if (!(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' ') + { + // already single space + } + else + { + m_hotkey.win = false; + m_hotkey.alt = false; + m_hotkey.shift = false; + m_hotkey.ctrl = false; + m_hotkey.key = ' '; + } + } + else + { + // If toggle off and current hotkey is bare space, revert to default (simplified policy) + if (!(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' ') + { + set_default_key_settings(); + } + } + + manage_space_mode_hook(); + Trace::SpaceModeEnabled(m_enableSpaceToActivate); } else { - Logger::info("Peek settings are empty"); - set_default_key_settings(); + // First-run (no existing settings file or empty JSON): default to Space-only activation + Logger::info("Peek settings are empty - initializing first-run defaults (Space activation)"); + m_enableSpaceToActivate = true; + m_hotkey.win = false; + m_hotkey.alt = false; + m_hotkey.shift = false; + m_hotkey.ctrl = false; + m_hotkey.key = ' '; + Trace::SpaceModeEnabled(true); } } @@ -129,6 +190,111 @@ private: m_hotkey.key = ' '; } + // Eligibility recompute (debounced via timer) +public: // callable from anonymous namespace helper + void recompute_space_mode_eligibility() + { + if (!m_enableSpaceToActivate) + { + g_foregroundEligible.store(false, std::memory_order_relaxed); + return; + } + const bool eligible = is_peek_or_explorer_or_desktop_window_focused(); + g_foregroundEligible.store(eligible, std::memory_order_relaxed); + Logger::debug(L"Peek space-mode eligibility recomputed: {}", eligible); + } + +private: + static void CALLBACK ForegroundDebounceTimerProc(PVOID /*param*/, BOOLEAN /*fired*/) + { + if (!g_instance || !g_foregroundHookActive.load(std::memory_order_relaxed)) + { + return; + } + g_instance->recompute_space_mode_eligibility(); + } + + static void CALLBACK ForegroundWinEventProc(HWINEVENTHOOK /*hook*/, DWORD /*event*/, HWND /*hwnd*/, LONG /*idObject*/, LONG /*idChild*/, DWORD /*thread*/, DWORD /*time*/) + { + if (!g_foregroundHookActive.load(std::memory_order_relaxed) || !g_instance) + { + return; + } + const DWORD now = GetTickCount(); + const DWORD last = g_foregroundLastScheduleTick.load(std::memory_order_relaxed); + // If no timer or sufficient time since last schedule, create a new one. + if (!g_foregroundDebounceTimer || (now - last) >= FOREGROUND_DEBOUNCE_MS || now < last) + { + if (g_foregroundDebounceTimer) + { + // Best effort: cancel previous pending timer; ignore failure. + DeleteTimerQueueTimer(nullptr, g_foregroundDebounceTimer, INVALID_HANDLE_VALUE); + g_foregroundDebounceTimer = nullptr; + } + if (CreateTimerQueueTimer(&g_foregroundDebounceTimer, nullptr, ForegroundDebounceTimerProc, nullptr, FOREGROUND_DEBOUNCE_MS, 0, WT_EXECUTEDEFAULT)) + { + g_foregroundLastScheduleTick.store(now, std::memory_order_relaxed); + } + else + { + Logger::warn(L"Peek failed to create foreground debounce timer"); + // Fallback: compute immediately if timer creation failed. + g_instance->recompute_space_mode_eligibility(); + } + } + } + + void install_foreground_hook() + { + if (g_foregroundHook || !m_enableSpaceToActivate) + { + return; + } + + g_instance = this; + g_foregroundHook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr, ForegroundWinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + if (g_foregroundHook) + { + g_foregroundHookActive.store(true, std::memory_order_relaxed); + recompute_space_mode_eligibility(); + } + else + { + g_foregroundHookActive.store(false, std::memory_order_relaxed); + Logger::warn(L"Peek failed to install foreground hook. Falling back to polling."); + } + } + + void uninstall_foreground_hook() + { + if (g_foregroundHook) + { + UnhookWinEvent(g_foregroundHook); + g_foregroundHook = nullptr; + } + if (g_foregroundDebounceTimer) + { + DeleteTimerQueueTimer(nullptr, g_foregroundDebounceTimer, INVALID_HANDLE_VALUE); + g_foregroundDebounceTimer = nullptr; + } + g_foregroundLastScheduleTick.store(0, std::memory_order_relaxed); + g_foregroundHookActive.store(false, std::memory_order_relaxed); + g_foregroundEligible.store(false, std::memory_order_relaxed); + g_instance = nullptr; + } + + void manage_space_mode_hook() + { + if (m_enableSpaceToActivate && m_enabled) + { + install_foreground_hook(); + } + else + { + uninstall_foreground_hook(); + } + } + void parse_hotkey(winrt::Windows::Data::Json::JsonObject& jsonHotkeyObject) { try @@ -319,6 +485,7 @@ private: public: Peek() { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", "Peek"); init_settings(); m_hInvokeEvent = CreateDefaultEvent(CommonSharedConstants::SHOW_PEEK_SHARED_EVENT); @@ -331,6 +498,7 @@ public: { } m_enabled = false; + uninstall_foreground_hook(); }; // Destroy the powertoy and free memory @@ -364,6 +532,7 @@ public: // Create a Settings object. PowerToysSettings::Settings settings(hinstance, get_name()); settings.set_description(MODULE_DESC); + settings.add_bool_toggle(JSON_KEY_ENABLE_SPACE_TO_ACTIVATE, L"Enable single Space key activation", m_enableSpaceToActivate); return settings.serialize_to_buffer(buffer, buffer_size); } @@ -395,6 +564,7 @@ public: launch_process(); m_enabled = true; Trace::EnablePeek(true); + manage_space_mode_hook(); } // Disable the powertoy @@ -425,6 +595,7 @@ public: m_enabled = false; Trace::EnablePeek(false); + uninstall_foreground_hook(); } // Returns if the powertoys is enabled @@ -454,11 +625,21 @@ public: { if (m_enabled) { - Logger::trace(L"Peek hotkey pressed"); - - // Only activate and consume the shortcut if a Peek, explorer or desktop window is the foreground application. - if (is_peek_or_explorer_or_desktop_window_focused()) + bool spaceMode = m_enableSpaceToActivate && !(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' '; + bool eligible = false; + if (spaceMode && g_foregroundHookActive.load(std::memory_order_relaxed)) { + eligible = g_foregroundEligible.load(std::memory_order_relaxed); + } + else + { + eligible = is_peek_or_explorer_or_desktop_window_focused(); + } + + if (eligible) + { + Logger::trace(L"Peek hotkey pressed and eligible for launching"); + // TODO: fix VK_SPACE DestroyWindow in viewer app if (!is_viewer_running()) { @@ -468,7 +649,16 @@ public: SetEvent(m_hInvokeEvent); Trace::PeekInvoked(); - return true; + + + if (spaceMode) + { + return false; + } + else + { + return true; + } } } diff --git a/src/modules/peek/peek/trace.cpp b/src/modules/peek/peek/trace.cpp index 529abb94f3..a1dd6355a2 100644 --- a/src/modules/peek/peek/trace.cpp +++ b/src/modules/peek/peek/trace.cpp @@ -48,3 +48,13 @@ void Trace::SettingsTelemetry(PowertoyModuleIface::Hotkey& hotkey) noexcept TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), TraceLoggingWideString(hotKeyStr.c_str(), "HotKey")); } + +void Trace::SpaceModeEnabled(bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "Peek_SpaceModeEnabled", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} diff --git a/src/modules/peek/peek/trace.h b/src/modules/peek/peek/trace.h index c250fc6b45..b5c22e7645 100644 --- a/src/modules/peek/peek/trace.h +++ b/src/modules/peek/peek/trace.h @@ -15,4 +15,7 @@ public: // Event to send settings telemetry. static void Trace::SettingsTelemetry(PowertoyModuleIface::Hotkey& hotkey) noexcept; + // Space mode telemetry (single-key activation toggle) + static void SpaceModeEnabled(bool enabled) noexcept; + }; diff --git a/src/modules/poweraccent/PowerAccent.Core/Languages.cs b/src/modules/poweraccent/PowerAccent.Core/Languages.cs index 6e329ffe7f..06c3a2bea3 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Languages.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Languages.cs @@ -212,7 +212,7 @@ namespace PowerAccent.Core LetterKey.VK_L => new[] { "ļ", "₺" }, // ₺ is in VK_T for other languages, but not VK_L, so we add it here. LetterKey.VK_M => new[] { "ṁ" }, LetterKey.VK_N => new[] { "ņ", "ṅ", "ⁿ", "ℕ", "№" }, - LetterKey.VK_O => new[] { "ȯ", "∅" }, + LetterKey.VK_O => new[] { "ȯ", "∅", "⌀" }, LetterKey.VK_P => new[] { "ṗ", "℗", "∏", "¶" }, LetterKey.VK_Q => new[] { "ℚ" }, LetterKey.VK_R => new[] { "ṙ", "®", "ℝ" }, diff --git a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj index f9e245559c..16272dba69 100644 --- a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj +++ b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj @@ -53,7 +53,7 @@ true true true - legacy_stdio_definitions.lib;$(VCToolsInstallDir)lib\$(Platform)\libsancov.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) + legacy_stdio_definitions.lib;windowscodecs.lib;$(VCToolsInstallDir)lib\$(Platform)\libsancov.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) xcopy /y "$(VCToolsInstallDir)bin\Hostx64\x64\clang_rt.asan_dynamic-x86_64.dll" "$(OutDir)" @@ -72,6 +72,7 @@ Console + windowscodecs.lib;%(AdditionalDependencies) diff --git a/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj b/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj index af3c71ad8e..a101c28ac9 100644 --- a/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj +++ b/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj @@ -51,7 +51,7 @@ Windows true false - runtimeobject.lib;%(AdditionalDependencies) + runtimeobject.lib;windowscodecs.lib;%(AdditionalDependencies) Source.def @@ -75,7 +75,7 @@ MakeAppx.exe pack /d . /p $(OutDir)PowerRenameContextMenuPackage.msix /nvtrue true false - runtimeobject.lib;%(AdditionalDependencies) + runtimeobject.lib;windowscodecs.lib;%(AdditionalDependencies) Source.def diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj index 9587b0dac0..eb21a94049 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj @@ -1,7 +1,17 @@  - + + + + + + + + + + + true @@ -69,7 +79,7 @@ _DEBUG;%(PreprocessorDefinitions) - kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;%(AdditionalDependencies) + kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies) @@ -79,7 +89,7 @@ true true - kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;%(AdditionalDependencies) + kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies) @@ -205,13 +215,20 @@ - + + - + + + + + + + - + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. @@ -220,9 +237,23 @@ + + - - + + + + + + + + + + + + + + diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp index 67f1834499..b46c5e548d 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp @@ -1,4 +1,4 @@ -#include "pch.h" +#include "pch.h" #include "App.xaml.h" #include "MainWindow.xaml.h" @@ -117,6 +117,9 @@ App::App() /// Details about the launch request and process. void App::OnLaunched(LaunchActivatedEventArgs const&) { + // WinUI3 framework automatically initializes COM as STA on the main thread + // No manual initialization needed for WIC operations + LoggerHelpers::init_logger(moduleName, L"", LogSettings::powerRenameLoggerName); if (powertoys_gpo::getConfiguredPowerRenameEnabledValue() == powertoys_gpo::gpo_rule_configured_disabled) @@ -237,7 +240,6 @@ void App::OnLaunched(LaunchActivatedEventArgs const&) } #else #define BUFSIZE 4096 * 4 - BOOL bSuccess; WCHAR chBuf[BUFSIZE]; DWORD dwRead; @@ -269,4 +271,4 @@ void App::OnLaunched(LaunchActivatedEventArgs const&) window = make(); window.Activate(); -} \ No newline at end of file +} diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl index 041e3d1921..bb02ec2e14 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl @@ -16,6 +16,7 @@ namespace PowerRenameUI Windows.Foundation.Collections.IObservableVector DateTimeShortcuts { get; }; Windows.Foundation.Collections.IObservableVector CounterShortcuts { get; }; Windows.Foundation.Collections.IObservableVector RandomizerShortcuts { get; }; + Windows.Foundation.Collections.IObservableVector MetadataShortcuts { get; }; String OriginalCount; String RenamedCount; diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml index 7126a63604..1c67d9a73b 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml @@ -330,6 +330,8 @@ + + + + + + + + + + + + + + + + + + + + @@ -560,31 +604,61 @@ FontFamily="{ThemeResource SymbolThemeFontFamily}" /> - + + + + + + + + + + + + + - - + + + + + + + + + diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp index 7cc3bf9543..01c7c517c2 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp @@ -1,4 +1,4 @@ -#include "pch.h" +#include "pch.h" #include "MainWindow.xaml.h" #if __has_include("MainWindow.g.cpp") #include "MainWindow.g.cpp" @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -225,6 +226,11 @@ namespace winrt::PowerRenameUI::implementation m_RandomizerShortcuts.Append(winrt::make(L"${rstringdigit=36}", manager.MainResourceMap().GetValue(L"Resources/RandomizerCheatSheet_Digit").ValueAsString())); m_RandomizerShortcuts.Append(winrt::make(L"${ruuidv4}", manager.MainResourceMap().GetValue(L"Resources/RandomizerCheatSheet_Uuid").ValueAsString())); + // Initialize metadata shortcuts - will be populated based on selected metadata type + m_metadataShortcuts = winrt::single_threaded_observable_vector(); + // Initialize with EXIF patterns (default) + UpdateMetadataShortcuts(PowerRenameLib::MetadataType::EXIF); + InitializeComponent(); m_etwTrace.UpdateState(true); @@ -356,7 +362,10 @@ namespace winrt::PowerRenameUI::implementation hstring MainWindow::OriginalCount() { UINT count = 0; - m_prManager->GetItemCount(&count); + if (m_prManager) + { + m_prManager->GetItemCount(&count); + } return hstring{ std::to_wstring(count) }; } @@ -394,13 +403,16 @@ namespace winrt::PowerRenameUI::implementation button_showAll().IsChecked(true); button_showRenamed().IsChecked(false); - DWORD filter = 0; - m_prManager->GetFilter(&filter); - if (filter != PowerRenameFilters::None) + if (m_prManager) { - m_prManager->SwitchFilter(0); - get_self(m_explorerItems)->SetIsFiltered(false); - InvalidateItemListViewState(); + DWORD filter = 0; + m_prManager->GetFilter(&filter); + if (filter != PowerRenameFilters::None) + { + m_prManager->SwitchFilter(0); + get_self(m_explorerItems)->SetIsFiltered(false); + InvalidateItemListViewState(); + } } } @@ -409,14 +421,17 @@ namespace winrt::PowerRenameUI::implementation button_showRenamed().IsChecked(true); button_showAll().IsChecked(false); - DWORD filter = 0; - m_prManager->GetFilter(&filter); - if (filter != PowerRenameFilters::ShouldRename) + if (m_prManager) { - m_prManager->SwitchFilter(0); - UpdateCounts(); - get_self(m_explorerItems)->SetIsFiltered(true); - InvalidateItemListViewState(); + DWORD filter = 0; + m_prManager->GetFilter(&filter); + if (filter != PowerRenameFilters::ShouldRename) + { + m_prManager->SwitchFilter(0); + UpdateCounts(); + get_self(m_explorerItems)->SetIsFiltered(true); + InvalidateItemListViewState(); + } } } @@ -434,6 +449,27 @@ namespace winrt::PowerRenameUI::implementation textBox_replace().Text(textBox_replace().Text() + s->Code()); } + void MainWindow::MetadataItemClick(winrt::Windows::Foundation::IInspectable const&, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e) + { + auto s = e.ClickedItem().try_as(); + DateTimeFlyout().Hide(); + textBox_replace().Text(textBox_replace().Text() + s->Code()); + } + + void MainWindow::MetadataSourceComboBox_SelectionChanged(winrt::Windows::Foundation::IInspectable const&, winrt::Microsoft::UI::Xaml::Controls::SelectionChangedEventArgs const&) + { + int selectedIndex = comboBox_metadataSource().SelectedIndex(); + + // Get the selected metadata type based on ComboBox selection + PowerRenameLib::MetadataType metadataType = static_cast(selectedIndex); + + // Update the metadata shortcuts list + UpdateMetadataShortcuts(metadataType); + + // Update the metadata source flags + UpdateMetadataSourceFlags(selectedIndex); + } + void MainWindow::button_rename_Click(winrt::Microsoft::UI::Xaml::Controls::SplitButton const&, winrt::Microsoft::UI::Xaml::Controls::SplitButtonClickEventArgs const&) { Rename(false); @@ -621,6 +657,12 @@ namespace winrt::PowerRenameUI::implementation { _TRACER_; + if (!m_prManager) + { + // Manager not initialized yet, ignore flag updates + return; + } + DWORD flags{}; m_prManager->GetFlags(&flags); @@ -818,6 +860,7 @@ namespace winrt::PowerRenameUI::implementation UpdateFlag(ModificationTime, UpdateFlagCommand::Reset); } }); + } void MainWindow::ToggleItem(int32_t id, bool checked) @@ -1049,6 +1092,15 @@ namespace winrt::PowerRenameUI::implementation { toggleButton_capitalize().IsChecked(true); } + + int metadataIndex = (flags & MetadataSourceXMP) ? 1 : 0; + if (comboBox_metadataSource().SelectedIndex() != metadataIndex) + { + comboBox_metadataSource().SelectedIndex(metadataIndex); + } + + auto metadataType = metadataIndex == 1 ? PowerRenameLib::MetadataType::XMP : PowerRenameLib::MetadataType::EXIF; + UpdateMetadataShortcuts(metadataType); } void MainWindow::UpdateCounts() @@ -1081,6 +1133,220 @@ namespace winrt::PowerRenameUI::implementation RenamedCount(hstring{ std::to_wstring(m_renamingCount) }); } + void MainWindow::UpdateMetadataShortcuts(PowerRenameLib::MetadataType metadataType) + { + // Clear existing list + m_metadataShortcuts.Clear(); + + // Get supported patterns for the selected metadata type + auto supportedPatterns = PowerRenameLib::MetadataPatternExtractor::GetSupportedPatterns(metadataType); + + auto factory = winrt::get_activation_factory(); + ResourceManager manager = factory.CreateInstance(L"PowerToys.PowerRename.pri"); + + // Add each supported pattern to the list + for (const auto& pattern : supportedPatterns) + { + std::wstring resourceKey = L"Resources/MetadataCheatSheet_" + ConvertPatternToResourceKey(pattern); + winrt::hstring patternWithDollar = winrt::hstring(L"$" + pattern); + + try { + auto description = manager.MainResourceMap().GetValue(resourceKey).ValueAsString(); + m_metadataShortcuts.Append(winrt::make(patternWithDollar, description)); + } + catch (...) { + // If resource doesn't exist, use the pattern name as description + m_metadataShortcuts.Append(winrt::make(patternWithDollar, winrt::hstring(pattern))); + } + } + } + + std::wstring MainWindow::ConvertPatternToResourceKey(const std::wstring& pattern) + { + // Special cases for patterns that don't follow the standard naming convention + if (pattern == L"TITLE") + { + return L"DocTitle"; + } + else if (pattern == L"DATE_TAKEN_YYYY") + { + return L"DateTakenYear4"; + } + else if (pattern == L"DATE_TAKEN_YY") + { + return L"DateTakenYear2"; + } + else if (pattern == L"DATE_TAKEN_MM") + { + return L"DateTakenMonth"; + } + else if (pattern == L"DATE_TAKEN_DD") + { + return L"DateTakenDay"; + } + else if (pattern == L"DATE_TAKEN_HH") + { + return L"DateTakenHour"; + } + else if (pattern == L"DATE_TAKEN_mm") + { + return L"DateTakenMinute"; + } + else if (pattern == L"DATE_TAKEN_SS") + { + return L"DateTakenSecond"; + } + else if (pattern == L"CREATE_DATE_YYYY") + { + return L"CreateDateYear4"; + } + else if (pattern == L"CREATE_DATE_YY") + { + return L"CreateDateYear2"; + } + else if (pattern == L"CREATE_DATE_MM") + { + return L"CreateDateMonth"; + } + else if (pattern == L"CREATE_DATE_DD") + { + return L"CreateDateDay"; + } + else if (pattern == L"CREATE_DATE_HH") + { + return L"CreateDateHour"; + } + else if (pattern == L"CREATE_DATE_mm") + { + return L"CreateDateMinute"; + } + else if (pattern == L"CREATE_DATE_SS") + { + return L"CreateDateSecond"; + } + else if (pattern == L"MODIFY_DATE_YYYY") + { + return L"ModifyDateYear4"; + } + else if (pattern == L"MODIFY_DATE_YY") + { + return L"ModifyDateYear2"; + } + else if (pattern == L"MODIFY_DATE_MM") + { + return L"ModifyDateMonth"; + } + else if (pattern == L"MODIFY_DATE_DD") + { + return L"ModifyDateDay"; + } + else if (pattern == L"MODIFY_DATE_HH") + { + return L"ModifyDateHour"; + } + else if (pattern == L"MODIFY_DATE_mm") + { + return L"ModifyDateMinute"; + } + else if (pattern == L"MODIFY_DATE_SS") + { + return L"ModifyDateSecond"; + } + else if (pattern == L"METADATA_DATE_YYYY") + { + return L"MetadataDateYear4"; + } + else if (pattern == L"METADATA_DATE_YY") + { + return L"MetadataDateYear2"; + } + else if (pattern == L"METADATA_DATE_MM") + { + return L"MetadataDateMonth"; + } + else if (pattern == L"METADATA_DATE_DD") + { + return L"MetadataDateDay"; + } + else if (pattern == L"METADATA_DATE_HH") + { + return L"MetadataDateHour"; + } + else if (pattern == L"METADATA_DATE_mm") + { + return L"MetadataDateMinute"; + } + else if (pattern == L"METADATA_DATE_SS") + { + return L"MetadataDateSecond"; + } + else if (pattern == L"ISO") + { + return L"ISO"; + } + else if (pattern == L"TITLE") + { + return L"DocTitle"; + } + else if (pattern == L"DESCRIPTION") + { + return L"DocDescription"; + } + else if (pattern == L"CREATOR") + { + return L"DocCreator"; + } + else if (pattern == L"SUBJECT") + { + return L"DocSubject"; + } + else if (pattern == L"RIGHTS") + { + return L"Rights"; + } + + // Convert pattern name to resource key format + // e.g., "CAMERA_MAKE" -> "CameraMake" + std::wstring result; + bool capitalizeNext = true; + + for (wchar_t ch : pattern) + { + if (ch == L'_') + { + capitalizeNext = true; + } + else + { + if (capitalizeNext) + { + result += static_cast(std::toupper(ch)); + capitalizeNext = false; + } + else + { + result += static_cast(std::tolower(ch)); + } + } + } + + return result; + } + + void MainWindow::UpdateMetadataSourceFlags(int selectedIndex) + { + // Clear all metadata source flags first + UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Reset); + UpdateFlag(MetadataSourceXMP, UpdateFlagCommand::Reset); + + // Set the appropriate metadata source flag based on selection + switch(selectedIndex) { + case 0: UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Set); break; + case 1: UpdateFlag(MetadataSourceXMP, UpdateFlagCommand::Set); break; + default: UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Set); break; // Default to EXIF + } + } + HRESULT MainWindow::OnRename(_In_ IPowerRenameItem* /*renameItem*/) { UpdateCounts(); @@ -1122,3 +1388,6 @@ namespace winrt::PowerRenameUI::implementation return S_OK; } } + + + diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h index 8c70194f1b..cff802f582 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h @@ -1,4 +1,4 @@ -#pragma once +#pragma once #include "winrt/Windows.UI.Xaml.h" #include "winrt/Windows.UI.Xaml.Markup.h" @@ -20,6 +20,8 @@ #include #include #include +#include +#include namespace winrt::PowerRenameUI::implementation { @@ -88,6 +90,7 @@ namespace winrt::PowerRenameUI::implementation winrt::Windows::Foundation::Collections::IObservableVector DateTimeShortcuts() { return m_dateTimeShortcuts; } winrt::Windows::Foundation::Collections::IObservableVector CounterShortcuts() { return m_CounterShortcuts; } winrt::Windows::Foundation::Collections::IObservableVector RandomizerShortcuts() { return m_RandomizerShortcuts; } + winrt::Windows::Foundation::Collections::IObservableVector MetadataShortcuts() { return m_metadataShortcuts; } hstring OriginalCount(); void OriginalCount(hstring value); @@ -111,6 +114,7 @@ namespace winrt::PowerRenameUI::implementation winrt::Windows::Foundation::Collections::IObservableVector m_dateTimeShortcuts; winrt::Windows::Foundation::Collections::IObservableVector m_CounterShortcuts; winrt::Windows::Foundation::Collections::IObservableVector m_RandomizerShortcuts; + winrt::Windows::Foundation::Collections::IObservableVector m_metadataShortcuts; // Used by PowerRenameManagerEvents HRESULT OnRename(_In_ IPowerRenameItem* renameItem); @@ -144,6 +148,9 @@ namespace winrt::PowerRenameUI::implementation HRESULT OpenSettingsApp(); void SetCheckboxesFromFlags(DWORD flags); void UpdateCounts(); + void UpdateMetadataShortcuts(PowerRenameLib::MetadataType metadataType); + std::wstring ConvertPatternToResourceKey(const std::wstring& pattern); + void UpdateMetadataSourceFlags(int selectedIndex); Shared::Trace::ETWTrace m_etwTrace{}; @@ -167,6 +174,8 @@ namespace winrt::PowerRenameUI::implementation public: void RegExItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); void DateTimeItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); + void MetadataItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); + void MetadataSourceComboBox_SelectionChanged(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::SelectionChangedEventArgs const& e); void button_rename_Click(winrt::Microsoft::UI::Xaml::Controls::SplitButton const& sender, winrt::Microsoft::UI::Xaml::Controls::SplitButtonClickEventArgs const& args); void MenuFlyoutItem_Click(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::RoutedEventArgs const& e); void OpenDocs(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::RoutedEventArgs const& e); @@ -179,3 +188,4 @@ namespace winrt::PowerRenameUI::factory_implementation { }; } + diff --git a/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw b/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw index 9af9e2365b..178106908d 100644 --- a/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw +++ b/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw @@ -414,6 +414,9 @@ Time used for replacement + + Metadata source for replacement + Creation Time @@ -423,4 +426,149 @@ Access Time + + + EXIF Metadata + + + XMP Metadata + + + + Replace with media metadata + + + Camera manufacturer name + + + Camera model name + + + Lens model name + + + ISO sensitivity value + + + F-number aperture value + + + Shutter speed value + + + Focal length in millimeters + + + Flash status (On/Off) + + + Image width in pixels + + + Image height in pixels + + + Image author/artist + + + Copyright information + + + GPS latitude coordinate + + + GPS longitude coordinate + + + GPS altitude in meters + + + Exposure compensation value + + + Image orientation + + + Color space information + + + Year photo was taken (4 digits) + + + Year photo was taken (2 digits) + + + Month photo was taken (01-12) + + + Day photo was taken (01-31) + + + Hour photo was taken (00-23) + + + Minute photo was taken (00-59) + + + Second photo was taken (00-59) + + + Year from XMP create date (4 digits) + + + Year from XMP create date (2 digits) + + + Month from XMP create date (01-12) + + + Day from XMP create date (01-31) + + + Hour from XMP create date (00-23) + + + Minute from XMP create date (00-59) + + + Second from XMP create date (00-59) + + + + + Software used to create/edit + + + + + Document title + + + Document description + + + Document creator/author + + + Keywords/tags + + + + + Copyright/rights information + + + + + Document unique identifier + + + Instance unique identifier + + + Original document identifier + + + Version identifier + \ No newline at end of file diff --git a/src/modules/powerrename/PowerRenameUILib/packages.config b/src/modules/powerrename/PowerRenameUILib/packages.config index 4a91705aea..74414b51bc 100644 --- a/src/modules/powerrename/PowerRenameUILib/packages.config +++ b/src/modules/powerrename/PowerRenameUILib/packages.config @@ -6,5 +6,14 @@ - + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj b/src/modules/powerrename/dll/PowerRenameExt.vcxproj index 9c612a08ff..2364012861 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj @@ -24,7 +24,7 @@ ..\lib\;..\PowerRenameUILib\;..\;..\..\..\;..\..\..\common\telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) - Pathcch.lib;comctl32.lib;shcore.lib;%(AdditionalDependencies) + Pathcch.lib;comctl32.lib;shcore.lib;windowscodecs.lib;%(AdditionalDependencies) PowerRenameExt.def gdi32.dll;shell32.dll;ole32.dll;shlwapi.dll;oleaut32.dll;%(DelayLoadDLLs) diff --git a/src/modules/powerrename/lib/Helpers.cpp b/src/modules/powerrename/lib/Helpers.cpp index 2515a8a7ae..c3902c7b93 100644 --- a/src/modules/powerrename/lib/Helpers.cpp +++ b/src/modules/powerrename/lib/Helpers.cpp @@ -1,9 +1,13 @@ #include "pch.h" #include "Helpers.h" +#include "MetadataTypes.h" #include #include #include #include +#include +#include +#include namespace fs = std::filesystem; @@ -12,6 +16,50 @@ namespace const int MAX_INPUT_STRING_LEN = 1024; const wchar_t c_rootRegPath[] = L"Software\\Microsoft\\PowerRename"; + + // Helper function: Find the longest matching pattern starting at the given position + // Returns the matched pattern name, or empty string if no match found + std::wstring FindLongestPattern( + const std::wstring& input, + size_t startPos, + size_t maxPatternLength, + const std::unordered_set& validPatterns) + { + const size_t remaining = input.length() - startPos; + const size_t searchLength = std::min(maxPatternLength, remaining); + + // Try to match from longest to shortest to ensure greedy matching + // e.g., DATE_TAKEN_YYYY should be matched before DATE_TAKEN_YY + for (size_t len = searchLength; len > 0; --len) + { + std::wstring candidate = input.substr(startPos, len); + if (validPatterns.find(candidate) != validPatterns.end()) + { + return candidate; + } + } + + return L""; + } + + // Helper function: Get the replacement value for a pattern + // Returns the actual metadata value if available; if not, returns the pattern name with $ prefix + std::wstring GetPatternValue( + const std::wstring& patternName, + const PowerRenameLib::MetadataPatternMap& patterns) + { + auto it = patterns.find(patternName); + + // Return actual value if found and valid (non-empty) + if (it != patterns.end() && !it->second.empty()) + { + return it->second; + } + + // Return pattern name with $ prefix if value is unavailable + // This provides visual feedback that the field exists but has no data + return L"$" + patternName; + } } HRESULT GetTrimmedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source) @@ -271,6 +319,72 @@ bool isFileTimeUsed(_In_ PCWSTR source) return used; } +bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataType, _In_opt_ PCWSTR filePath, bool isFolder) +{ + if (!source) return false; + + // Early exit: If file path is provided, check file type first (fastest checks) + // This avoids expensive pattern matching for files that don't support metadata + if (filePath != nullptr) + { + // Folders don't support metadata extraction + if (isFolder) + { + return false; + } + + // Check if file path is valid + if (wcslen(filePath) == 0) + { + return false; + } + + // Get file extension + std::wstring extension = fs::path(filePath).extension().wstring(); + + // Convert to lowercase for case-insensitive comparison + std::transform(extension.begin(), extension.end(), extension.begin(), ::towlower); + + // According to the metadata support table, only these formats support metadata extraction: + // - JPEG (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding + // - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding + // - PNG (text chunks) + static const std::unordered_set supportedExtensions = { + L".jpg", + L".jpeg", + L".png", + L".tif", + L".tiff" + }; + + // If file type doesn't support metadata, no need to check patterns + if (supportedExtensions.find(extension) == supportedExtensions.end()) + { + return false; + } + } + + // Now check if any metadata pattern exists in the source string + // This is the most expensive check, so we do it last + std::wstring str(source); + + // Get supported patterns for the specified metadata type + auto supportedPatterns = PowerRenameLib::MetadataPatternExtractor::GetSupportedPatterns(metadataType); + + // Check if any metadata pattern exists in the source string + for (const auto& pattern : supportedPatterns) + { + std::wstring searchPattern = L"$" + pattern; + if (str.find(searchPattern) != std::wstring::npos) + { + return true; + } + } + + // No metadata pattern found + return false; +} + HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime) { std::locale::global(std::locale("")); @@ -297,10 +411,10 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YYYY"), replaceTerm); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", (fileTime.wYear % 100)); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YY"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YY(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $YYY, $YYYY, or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", (fileTime.wYear % 10)); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$Y"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$Y(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $YY, $YYYY, or metadata patterns GetDateFormatEx(localeName, NULL, &fileTime, L"MMMM", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); @@ -310,13 +424,13 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY GetDateFormatEx(localeName, NULL, &fileTime, L"MMM", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MMM"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MMM(?!M)"), replaceTerm); // Negative lookahead prevents matching $MMMM StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMonth); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MM"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MM(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $MMM, $MMMM, or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMonth); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$M"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$M(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $MM, $MMM, $MMMM, or metadata patterns GetDateFormatEx(localeName, NULL, &fileTime, L"dddd", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); @@ -326,19 +440,19 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY GetDateFormatEx(localeName, NULL, &fileTime, L"ddd", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DDD"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DDD(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $DDDD or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wDay); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DD"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DD(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $DDD, $DDDD, or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wDay); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$D"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$D(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $DD, $DDD, $DDDD, or metadata patterns like $DATE_TAKEN_YYYY StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", hour12); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$HH"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$HH(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $HHH or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", hour12); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$H"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$H(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $HH or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", (fileTime.wHour < 12) ? L"AM" : L"PM"); res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$TT"), replaceTerm); @@ -347,31 +461,31 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$tt"), replaceTerm); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wHour); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$hh"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$hh(?!h)"), replaceTerm); // Negative lookahead prevents matching $hhh StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wHour); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$h"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$h(?!h)"), replaceTerm); // Negative lookahead prevents matching $hh StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMinute); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$mm"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$mm(?!m)"), replaceTerm); // Negative lookahead prevents matching $mmm StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMinute); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$m"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$m(?!m)"), replaceTerm); // Negative lookahead prevents matching $mm StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wSecond); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ss"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ss(?!s)"), replaceTerm); // Negative lookahead prevents matching $sss StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wSecond); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$s"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$s(?!s)"), replaceTerm); // Negative lookahead prevents matching $ss StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%03d"), L"$01", fileTime.wMilliseconds); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$fff"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$fff(?!f)"), replaceTerm); // Negative lookahead prevents matching $ffff StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMilliseconds / 10); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ff"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ff(?!f)"), replaceTerm); // Negative lookahead prevents matching $fff StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMilliseconds / 100); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$f"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$f(?!f)"), replaceTerm); // Negative lookahead prevents matching $ff or $fff hr = StringCchCopy(result, cchMax, res.c_str()); } @@ -379,6 +493,91 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY return hr; } +HRESULT GetMetadataFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, const PowerRenameLib::MetadataPatternMap& patterns) +{ + if (!source || wcslen(source) == 0) + { + return E_INVALIDARG; + } + + std::wstring input(source); + std::wstring output; + output.reserve(input.length() * 2); // Reserve space to avoid frequent reallocations + + // Build pattern lookup table for fast validation + // Using all possible patterns to recognize valid pattern names even when metadata is unavailable + auto allPatterns = PowerRenameLib::MetadataPatternExtractor::GetAllPossiblePatterns(); + std::unordered_set validPatterns; + validPatterns.reserve(allPatterns.size()); + size_t maxPatternLength = 0; + for (const auto& pattern : allPatterns) + { + validPatterns.insert(pattern); + maxPatternLength = std::max(maxPatternLength, pattern.length()); + } + + size_t pos = 0; + while (pos < input.length()) + { + // Handle regular characters + if (input[pos] != L'$') + { + output += input[pos]; + pos++; + continue; + } + + // Count consecutive dollar signs + size_t dollarCount = 0; + while (pos < input.length() && input[pos] == L'$') + { + dollarCount++; + pos++; + } + + // Even number of dollars: all are escaped (e.g., $$ -> $, $$$$ -> $$) + if (dollarCount % 2 == 0) + { + output.append(dollarCount / 2, L'$'); + continue; + } + + // Odd number of dollars: pairs are escaped, last one might be a pattern prefix + // e.g., $ -> might be pattern, $$$ -> $ + might be pattern + size_t escapedDollars = dollarCount / 2; + + // If no more characters, output all dollar signs + if (pos >= input.length()) + { + output.append(dollarCount, L'$'); + continue; + } + + // Try to match a pattern (greedy matching for longest pattern) + std::wstring matchedPattern = FindLongestPattern(input, pos, maxPatternLength, validPatterns); + + if (matchedPattern.empty()) + { + // No pattern matched, output all dollar signs + output.append(dollarCount, L'$'); + } + else + { + // Pattern matched + output.append(escapedDollars, L'$'); // Output escaped dollars first + + // Replace pattern with its value or keep pattern name if value unavailable + std::wstring replacementValue = GetPatternValue(matchedPattern, patterns); + output += replacementValue; + + pos += matchedPattern.length(); + } + } + + return StringCchCopy(result, cchMax, output.c_str()); +} + + HRESULT GetShellItemArrayFromDataObject(_In_ IUnknown* dataSource, _COM_Outptr_ IShellItemArray** items) { *items = nullptr; @@ -707,4 +906,4 @@ std::wstring CreateGuidStringWithoutBrackets() } return L""; -} \ No newline at end of file +} diff --git a/src/modules/powerrename/lib/Helpers.h b/src/modules/powerrename/lib/Helpers.h index 05b8eab19d..83659637c9 100644 --- a/src/modules/powerrename/lib/Helpers.h +++ b/src/modules/powerrename/lib/Helpers.h @@ -1,13 +1,17 @@ #pragma once #include "PowerRenameInterfaces.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include HRESULT GetTrimmedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source); HRESULT GetTransformedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, DWORD flags, bool isFolder); HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime); +HRESULT GetMetadataFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, const PowerRenameLib::MetadataPatternMap& patterns); bool isFileTimeUsed(_In_ PCWSTR source); +bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataType, _In_opt_ PCWSTR filePath = nullptr, bool isFolder = false); bool ShellItemArrayContainsRenamableItem(_In_ IShellItemArray* shellItemArray); bool DataObjectContainsRenamableItem(_In_ IUnknown* dataSource); HRESULT GetShellItemArrayFromDataObject(_In_ IUnknown* dataSource, _COM_Outptr_ IShellItemArray** items); diff --git a/src/modules/powerrename/lib/MetadataFormatHelper.cpp b/src/modules/powerrename/lib/MetadataFormatHelper.cpp new file mode 100644 index 0000000000..e6b88e913a --- /dev/null +++ b/src/modules/powerrename/lib/MetadataFormatHelper.cpp @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "MetadataFormatHelper.h" +#include +#include +#include + +using namespace PowerRenameLib; + +// Formatting functions + +std::wstring MetadataFormatHelper::FormatAperture(double aperture) +{ + return std::format(L"f/{:.1f}", aperture); +} + +std::wstring MetadataFormatHelper::FormatShutterSpeed(double speed) +{ + if (speed <= 0.0) + { + return L"0"; + } + + if (speed >= 1.0) + { + return std::format(L"{:.1f}s", speed); + } + + const double reciprocal = std::round(1.0 / speed); + if (reciprocal <= 1.0) + { + return std::format(L"{:.3f}s", speed); + } + + return std::format(L"1/{:.0f}s", reciprocal); +} + +std::wstring MetadataFormatHelper::FormatISO(int64_t iso) +{ + if (iso <= 0) + { + return L"ISO"; + } + + return std::format(L"ISO {}", iso); +} + +std::wstring MetadataFormatHelper::FormatFlash(int64_t flashValue) +{ + switch (flashValue & 0x1) + { + case 0: + return L"Flash Off"; + case 1: + return L"Flash On"; + default: + break; + } + + return std::format(L"Flash 0x{:X}", static_cast(flashValue)); +} + +std::wstring MetadataFormatHelper::FormatCoordinate(double coord, bool isLatitude) +{ + wchar_t direction = isLatitude ? (coord >= 0.0 ? L'N' : L'S') : (coord >= 0.0 ? L'E' : L'W'); + double absolute = std::abs(coord); + int degrees = static_cast(absolute); + double minutes = (absolute - static_cast(degrees)) * 60.0; + + return std::format(L"{:d}°{:.2f}'{}", degrees, minutes, direction); +} + +std::wstring MetadataFormatHelper::FormatSystemTime(const SYSTEMTIME& st) +{ + return std::format(L"{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}", + st.wYear, + st.wMonth, + st.wDay, + st.wHour, + st.wMinute, + st.wSecond); +} + +// Parsing functions + +double MetadataFormatHelper::ParseGPSRational(const PROPVARIANT& pv) +{ + if ((pv.vt & VT_VECTOR) && pv.caub.cElems >= 8) + { + return ParseSingleRational(pv.caub.pElems, 0); + } + return 0.0; +} + +double MetadataFormatHelper::ParseSingleRational(const uint8_t* bytes, size_t offset) +{ + // Parse a single rational number (8 bytes: numerator + denominator) + if (!bytes) + return 0.0; + + // Note: Callers are responsible for ensuring the buffer is large enough. + // This function assumes offset points to at least 8 bytes of valid data. + // All current callers perform cElems >= required_size checks before calling. + const uint8_t* rationalBytes = bytes + offset; + + // Parse as little-endian uint32_t values + uint32_t numerator = static_cast(rationalBytes[0]) | + (static_cast(rationalBytes[1]) << 8) | + (static_cast(rationalBytes[2]) << 16) | + (static_cast(rationalBytes[3]) << 24); + + uint32_t denominator = static_cast(rationalBytes[4]) | + (static_cast(rationalBytes[5]) << 8) | + (static_cast(rationalBytes[6]) << 16) | + (static_cast(rationalBytes[7]) << 24); + + if (denominator != 0) + { + return static_cast(numerator) / static_cast(denominator); + } + + return 0.0; +} + +double MetadataFormatHelper::ParseSingleSRational(const uint8_t* bytes, size_t offset) +{ + // Parse a single signed rational number (8 bytes: signed numerator + signed denominator) + if (!bytes) + return 0.0; + + // Note: Callers are responsible for ensuring the buffer is large enough. + // This function assumes offset points to at least 8 bytes of valid data. + // All current callers perform cElems >= required_size checks before calling. + const uint8_t* rationalBytes = bytes + offset; + + // Parse as little-endian int32_t values (signed) + // First construct as unsigned, then reinterpret as signed + uint32_t numerator_uint = static_cast(rationalBytes[0]) | + (static_cast(rationalBytes[1]) << 8) | + (static_cast(rationalBytes[2]) << 16) | + (static_cast(rationalBytes[3]) << 24); + + uint32_t denominator_uint = static_cast(rationalBytes[4]) | + (static_cast(rationalBytes[5]) << 8) | + (static_cast(rationalBytes[6]) << 16) | + (static_cast(rationalBytes[7]) << 24); + + // Reinterpret as signed + int32_t numerator = static_cast(numerator_uint); + int32_t denominator = static_cast(denominator_uint); + + if (denominator != 0) + { + return static_cast(numerator) / static_cast(denominator); + } + + return 0.0; +} + +std::pair MetadataFormatHelper::ParseGPSCoordinates( + const PROPVARIANT& latitude, + const PROPVARIANT& longitude, + const PROPVARIANT& latRef, + const PROPVARIANT& lonRef) +{ + double lat = 0.0, lon = 0.0; + + // Parse latitude - typically stored as 3 rationals (degrees, minutes, seconds) + if ((latitude.vt & VT_VECTOR) && latitude.caub.cElems >= 24) // 3 rationals * 8 bytes each + { + const uint8_t* bytes = latitude.caub.pElems; + + // degrees, minutes, seconds (each rational is 8 bytes) + double degrees = ParseSingleRational(bytes, 0); + double minutes = ParseSingleRational(bytes, 8); + double seconds = ParseSingleRational(bytes, 16); + + lat = degrees + minutes / 60.0 + seconds / 3600.0; + } + + // Parse longitude + if ((longitude.vt & VT_VECTOR) && longitude.caub.cElems >= 24) + { + const uint8_t* bytes = longitude.caub.pElems; + + double degrees = ParseSingleRational(bytes, 0); + double minutes = ParseSingleRational(bytes, 8); + double seconds = ParseSingleRational(bytes, 16); + + lon = degrees + minutes / 60.0 + seconds / 3600.0; + } + + // Apply direction references (N/S for latitude, E/W for longitude) + if (latRef.vt == VT_LPSTR && latRef.pszVal) + { + if (strcmp(latRef.pszVal, "S") == 0) + lat = -lat; + } + + if (lonRef.vt == VT_LPSTR && lonRef.pszVal) + { + if (strcmp(lonRef.pszVal, "W") == 0) + lon = -lon; + } + + return { lat, lon }; +} + +std::wstring MetadataFormatHelper::SanitizeForFileName(const std::wstring& str) +{ + // Windows illegal filename characters: < > : " / \ | ? * + // Also control characters (0-31) and some others + std::wstring sanitized = str; + + // Replace illegal characters with underscore + for (auto& ch : sanitized) + { + // Check for illegal characters + if (ch == L'<' || ch == L'>' || ch == L':' || ch == L'"' || + ch == L'/' || ch == L'\\' || ch == L'|' || ch == L'?' || ch == L'*' || + ch < 32) // Control characters + { + ch = L'_'; + } + } + + // Also remove trailing dots and spaces (Windows doesn't like them at end of filename) + while (!sanitized.empty() && (sanitized.back() == L'.' || sanitized.back() == L' ')) + { + sanitized.pop_back(); + } + + return sanitized; +} diff --git a/src/modules/powerrename/lib/MetadataFormatHelper.h b/src/modules/powerrename/lib/MetadataFormatHelper.h new file mode 100644 index 0000000000..86208225cf --- /dev/null +++ b/src/modules/powerrename/lib/MetadataFormatHelper.h @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include +#include +#include +#include + +namespace PowerRenameLib +{ + /// + /// Helper class for formatting and parsing metadata values + /// Provides static utility functions for converting metadata to human-readable strings + /// and parsing raw metadata values + /// + class MetadataFormatHelper + { + public: + // Formatting functions - Convert metadata values to display strings + + /// + /// Format aperture value (f-number) + /// + /// Aperture value (e.g., 2.8) + /// Formatted string (e.g., "f/2.8") + static std::wstring FormatAperture(double aperture); + + /// + /// Format shutter speed + /// + /// Shutter speed in seconds + /// Formatted string (e.g., "1/100s" or "2.5s") + static std::wstring FormatShutterSpeed(double speed); + + /// + /// Format ISO value + /// + /// ISO speed value + /// Formatted string (e.g., "ISO 400") + static std::wstring FormatISO(int64_t iso); + + /// + /// Format flash status + /// + /// Flash value from EXIF + /// Formatted string (e.g., "Flash On" or "Flash Off") + static std::wstring FormatFlash(int64_t flashValue); + + /// + /// Format GPS coordinate + /// + /// Coordinate value in decimal degrees + /// true for latitude, false for longitude + /// Formatted string (e.g., "40°26.76'N") + static std::wstring FormatCoordinate(double coord, bool isLatitude); + + /// + /// Format SYSTEMTIME to string + /// + /// SYSTEMTIME structure + /// Formatted string (e.g., "2024-03-15 14:30:45") + static std::wstring FormatSystemTime(const SYSTEMTIME& st); + + // Parsing functions - Convert raw metadata to usable values + + /// + /// Parse GPS rational value from PROPVARIANT + /// + /// PROPVARIANT containing GPS rational data + /// Parsed double value + static double ParseGPSRational(const PROPVARIANT& pv); + + /// + /// Parse single rational value from byte array + /// + /// Byte array containing rational data + /// Offset in the byte array + /// Parsed double value (numerator / denominator) + static double ParseSingleRational(const uint8_t* bytes, size_t offset); + + /// + /// Parse single signed rational value from byte array + /// + /// Byte array containing signed rational data + /// Offset in the byte array + /// Parsed double value (signed numerator / signed denominator) + static double ParseSingleSRational(const uint8_t* bytes, size_t offset); + + /// + /// Parse GPS coordinates from PROPVARIANT values + /// + /// PROPVARIANT containing latitude + /// PROPVARIANT containing longitude + /// PROPVARIANT containing latitude reference (N/S) + /// PROPVARIANT containing longitude reference (E/W) + /// Pair of (latitude, longitude) in decimal degrees + static std::pair ParseGPSCoordinates( + const PROPVARIANT& latitude, + const PROPVARIANT& longitude, + const PROPVARIANT& latRef, + const PROPVARIANT& lonRef); + + /// + /// Sanitize a string to make it safe for use in filenames + /// Replaces illegal filename characters (< > : " / \ | ? * and control chars) with underscore + /// Also removes trailing dots and spaces which Windows doesn't allow at end of filename + /// + /// IMPORTANT: This should ONLY be called in ExtractPatterns to avoid performance waste. + /// Do NOT call this function when reading raw metadata values. + /// + /// String to sanitize + /// Sanitized string safe for use in filename + static std::wstring SanitizeForFileName(const std::wstring& str); + }; +} diff --git a/src/modules/powerrename/lib/MetadataPatternExtractor.cpp b/src/modules/powerrename/lib/MetadataPatternExtractor.cpp new file mode 100644 index 0000000000..cfbc40837d --- /dev/null +++ b/src/modules/powerrename/lib/MetadataPatternExtractor.cpp @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "MetadataPatternExtractor.h" +#include "MetadataFormatHelper.h" +#include "WICMetadataExtractor.h" +#include +#include +#include +#include +#include +#include + +using namespace PowerRenameLib; + +MetadataPatternExtractor::MetadataPatternExtractor() + : extractor(std::make_unique()) +{ +} + +MetadataPatternExtractor::~MetadataPatternExtractor() = default; + +MetadataPatternMap MetadataPatternExtractor::ExtractPatterns( + const std::wstring& filePath, + MetadataType type) +{ + MetadataPatternMap patterns; + + switch (type) + { + case MetadataType::EXIF: + patterns = ExtractEXIFPatterns(filePath); + break; + case MetadataType::XMP: + patterns = ExtractXMPPatterns(filePath); + break; + default: + return MetadataPatternMap(); + } + + // Sanitize all pattern values for filename safety before returning + // This ensures all metadata values are safe to use in filenames (removes illegal chars like <>:"/\|?*) + // IMPORTANT: Only call SanitizeForFileName here to avoid performance waste + for (auto& [key, value] : patterns) + { + value = MetadataFormatHelper::SanitizeForFileName(value); + } + + return patterns; +} + +void MetadataPatternExtractor::ClearCache() +{ + if (extractor) + { + extractor->ClearCache(); + } +} + +MetadataPatternMap MetadataPatternExtractor::ExtractEXIFPatterns(const std::wstring& filePath) +{ + MetadataPatternMap patterns; + + EXIFMetadata exif; + if (!extractor->ExtractEXIFMetadata(filePath, exif)) + { + return patterns; + } + + if (exif.cameraMake.has_value()) + { + patterns[MetadataPatterns::CAMERA_MAKE] = exif.cameraMake.value(); + } + + if (exif.cameraModel.has_value()) + { + patterns[MetadataPatterns::CAMERA_MODEL] = exif.cameraModel.value(); + } + + if (exif.lensModel.has_value()) + { + patterns[MetadataPatterns::LENS] = exif.lensModel.value(); + } + + if (exif.iso.has_value()) + { + patterns[MetadataPatterns::ISO] = MetadataFormatHelper::FormatISO(exif.iso.value()); + } + + if (exif.aperture.has_value()) + { + patterns[MetadataPatterns::APERTURE] = MetadataFormatHelper::FormatAperture(exif.aperture.value()); + } + + if (exif.shutterSpeed.has_value()) + { + patterns[MetadataPatterns::SHUTTER] = MetadataFormatHelper::FormatShutterSpeed(exif.shutterSpeed.value()); + } + + if (exif.focalLength.has_value()) + { + patterns[MetadataPatterns::FOCAL] = std::to_wstring(static_cast(exif.focalLength.value())) + L"mm"; + } + + if (exif.flash.has_value()) + { + patterns[MetadataPatterns::FLASH] = MetadataFormatHelper::FormatFlash(exif.flash.value()); + } + + if (exif.width.has_value()) + { + patterns[MetadataPatterns::WIDTH] = std::to_wstring(exif.width.value()); + } + + if (exif.height.has_value()) + { + patterns[MetadataPatterns::HEIGHT] = std::to_wstring(exif.height.value()); + } + + if (exif.author.has_value()) + { + patterns[MetadataPatterns::AUTHOR] = exif.author.value(); + } + + if (exif.copyright.has_value()) + { + patterns[MetadataPatterns::COPYRIGHT] = exif.copyright.value(); + } + + if (exif.latitude.has_value()) + { + patterns[MetadataPatterns::LATITUDE] = MetadataFormatHelper::FormatCoordinate(exif.latitude.value(), true); + } + + if (exif.longitude.has_value()) + { + patterns[MetadataPatterns::LONGITUDE] = MetadataFormatHelper::FormatCoordinate(exif.longitude.value(), false); + } + + // Only extract DATE_TAKEN patterns (most commonly used) + if (exif.dateTaken.has_value()) + { + const SYSTEMTIME& date = exif.dateTaken.value(); + patterns[MetadataPatterns::DATE_TAKEN_YYYY] = std::format(L"{:04d}", date.wYear); + patterns[MetadataPatterns::DATE_TAKEN_YY] = std::format(L"{:02d}", date.wYear % 100); + patterns[MetadataPatterns::DATE_TAKEN_MM] = std::format(L"{:02d}", date.wMonth); + patterns[MetadataPatterns::DATE_TAKEN_DD] = std::format(L"{:02d}", date.wDay); + patterns[MetadataPatterns::DATE_TAKEN_HH] = std::format(L"{:02d}", date.wHour); + patterns[MetadataPatterns::DATE_TAKEN_mm] = std::format(L"{:02d}", date.wMinute); + patterns[MetadataPatterns::DATE_TAKEN_SS] = std::format(L"{:02d}", date.wSecond); + } + // Note: dateDigitized and dateModified are still extracted but not exposed as patterns + + if (exif.exposureBias.has_value()) + { + patterns[MetadataPatterns::EXPOSURE_BIAS] = std::format(L"{:.2f}", exif.exposureBias.value()); + } + + if (exif.orientation.has_value()) + { + patterns[MetadataPatterns::ORIENTATION] = std::to_wstring(exif.orientation.value()); + } + + if (exif.colorSpace.has_value()) + { + patterns[MetadataPatterns::COLOR_SPACE] = std::to_wstring(exif.colorSpace.value()); + } + + if (exif.altitude.has_value()) + { + patterns[MetadataPatterns::ALTITUDE] = std::format(L"{:.2f} m", exif.altitude.value()); + } + + return patterns; +} + +MetadataPatternMap MetadataPatternExtractor::ExtractXMPPatterns(const std::wstring& filePath) +{ + MetadataPatternMap patterns; + + XMPMetadata xmp; + if (!extractor->ExtractXMPMetadata(filePath, xmp)) + { + return patterns; + } + + if (xmp.creator.has_value()) + { + const auto& creator = xmp.creator.value(); + patterns[MetadataPatterns::AUTHOR] = creator; + patterns[MetadataPatterns::CREATOR] = creator; + } + + if (xmp.rights.has_value()) + { + const auto& rights = xmp.rights.value(); + patterns[MetadataPatterns::RIGHTS] = rights; + patterns[MetadataPatterns::COPYRIGHT] = rights; + } + + if (xmp.title.has_value()) + { + patterns[MetadataPatterns::TITLE] = xmp.title.value(); + } + + if (xmp.description.has_value()) + { + patterns[MetadataPatterns::DESCRIPTION] = xmp.description.value(); + } + + if (xmp.subject.has_value()) + { + std::wstring joined; + for (const auto& entry : xmp.subject.value()) + { + if (!joined.empty()) + { + joined.append(L"; "); + } + joined.append(entry); + } + if (!joined.empty()) + { + patterns[MetadataPatterns::SUBJECT] = joined; + } + } + + if (xmp.creatorTool.has_value()) + { + patterns[MetadataPatterns::CREATOR_TOOL] = xmp.creatorTool.value(); + } + + if (xmp.documentID.has_value()) + { + patterns[MetadataPatterns::DOCUMENT_ID] = xmp.documentID.value(); + } + + if (xmp.instanceID.has_value()) + { + patterns[MetadataPatterns::INSTANCE_ID] = xmp.instanceID.value(); + } + + if (xmp.originalDocumentID.has_value()) + { + patterns[MetadataPatterns::ORIGINAL_DOCUMENT_ID] = xmp.originalDocumentID.value(); + } + + if (xmp.versionID.has_value()) + { + patterns[MetadataPatterns::VERSION_ID] = xmp.versionID.value(); + } + + // Only extract CREATE_DATE patterns (primary creation time) + if (xmp.createDate.has_value()) + { + const SYSTEMTIME& date = xmp.createDate.value(); + patterns[MetadataPatterns::CREATE_DATE_YYYY] = std::format(L"{:04d}", date.wYear); + patterns[MetadataPatterns::CREATE_DATE_YY] = std::format(L"{:02d}", date.wYear % 100); + patterns[MetadataPatterns::CREATE_DATE_MM] = std::format(L"{:02d}", date.wMonth); + patterns[MetadataPatterns::CREATE_DATE_DD] = std::format(L"{:02d}", date.wDay); + patterns[MetadataPatterns::CREATE_DATE_HH] = std::format(L"{:02d}", date.wHour); + patterns[MetadataPatterns::CREATE_DATE_mm] = std::format(L"{:02d}", date.wMinute); + patterns[MetadataPatterns::CREATE_DATE_SS] = std::format(L"{:02d}", date.wSecond); + } + // Note: modifyDate and metadataDate are still extracted but not exposed as patterns + + return patterns; +} + +// AddDatePatterns function has been removed as dynamic patterns are no longer supported. +// Date patterns are now directly added inline for DATE_TAKEN and CREATE_DATE only. +// Formatting functions have been moved to MetadataFormatHelper for better testability. + +std::vector MetadataPatternExtractor::GetSupportedPatterns(MetadataType type) +{ + switch (type) + { + case MetadataType::EXIF: + return { + MetadataPatterns::CAMERA_MAKE, + MetadataPatterns::CAMERA_MODEL, + MetadataPatterns::LENS, + MetadataPatterns::ISO, + MetadataPatterns::APERTURE, + MetadataPatterns::SHUTTER, + MetadataPatterns::FOCAL, + MetadataPatterns::FLASH, + MetadataPatterns::WIDTH, + MetadataPatterns::HEIGHT, + MetadataPatterns::AUTHOR, + MetadataPatterns::COPYRIGHT, + MetadataPatterns::LATITUDE, + MetadataPatterns::LONGITUDE, + MetadataPatterns::DATE_TAKEN_YYYY, + MetadataPatterns::DATE_TAKEN_YY, + MetadataPatterns::DATE_TAKEN_MM, + MetadataPatterns::DATE_TAKEN_DD, + MetadataPatterns::DATE_TAKEN_HH, + MetadataPatterns::DATE_TAKEN_mm, + MetadataPatterns::DATE_TAKEN_SS, + MetadataPatterns::EXPOSURE_BIAS, + MetadataPatterns::ORIENTATION, + MetadataPatterns::COLOR_SPACE, + MetadataPatterns::ALTITUDE + }; + + case MetadataType::XMP: + return { + MetadataPatterns::AUTHOR, + MetadataPatterns::COPYRIGHT, + MetadataPatterns::RIGHTS, + MetadataPatterns::TITLE, + MetadataPatterns::DESCRIPTION, + MetadataPatterns::SUBJECT, + MetadataPatterns::CREATOR, + MetadataPatterns::CREATOR_TOOL, + MetadataPatterns::DOCUMENT_ID, + MetadataPatterns::INSTANCE_ID, + MetadataPatterns::ORIGINAL_DOCUMENT_ID, + MetadataPatterns::VERSION_ID, + MetadataPatterns::CREATE_DATE_YYYY, + MetadataPatterns::CREATE_DATE_YY, + MetadataPatterns::CREATE_DATE_MM, + MetadataPatterns::CREATE_DATE_DD, + MetadataPatterns::CREATE_DATE_HH, + MetadataPatterns::CREATE_DATE_mm, + MetadataPatterns::CREATE_DATE_SS + }; + + default: + return {}; + } +} + +std::vector MetadataPatternExtractor::GetAllPossiblePatterns() +{ + auto exifPatterns = GetSupportedPatterns(MetadataType::EXIF); + auto xmpPatterns = GetSupportedPatterns(MetadataType::XMP); + + std::vector allPatterns; + allPatterns.reserve(exifPatterns.size() + xmpPatterns.size()); + + allPatterns.insert(allPatterns.end(), exifPatterns.begin(), exifPatterns.end()); + allPatterns.insert(allPatterns.end(), xmpPatterns.begin(), xmpPatterns.end()); + + std::sort(allPatterns.begin(), allPatterns.end()); + allPatterns.erase(std::unique(allPatterns.begin(), allPatterns.end()), allPatterns.end()); + + return allPatterns; +} + diff --git a/src/modules/powerrename/lib/MetadataPatternExtractor.h b/src/modules/powerrename/lib/MetadataPatternExtractor.h new file mode 100644 index 0000000000..787e5c437d --- /dev/null +++ b/src/modules/powerrename/lib/MetadataPatternExtractor.h @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include +#include +#include +#include +#include "MetadataTypes.h" + +namespace PowerRenameLib +{ + // Pattern-Value mapping for metadata replacement + using MetadataPatternMap = std::unordered_map; + + /// + /// Metadata pattern extractor that converts metadata into replaceable patterns + /// + class MetadataPatternExtractor + { + public: + MetadataPatternExtractor(); + ~MetadataPatternExtractor(); + + MetadataPatternMap ExtractPatterns(const std::wstring& filePath, MetadataType type); + + void ClearCache(); + + static std::vector GetSupportedPatterns(MetadataType type); + static std::vector GetAllPossiblePatterns(); + + private: + std::unique_ptr extractor; + + MetadataPatternMap ExtractEXIFPatterns(const std::wstring& filePath); + MetadataPatternMap ExtractXMPPatterns(const std::wstring& filePath); + }; +} diff --git a/src/modules/powerrename/lib/MetadataResultCache.cpp b/src/modules/powerrename/lib/MetadataResultCache.cpp new file mode 100644 index 0000000000..5f30b24abe --- /dev/null +++ b/src/modules/powerrename/lib/MetadataResultCache.cpp @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "MetadataResultCache.h" + +using namespace PowerRenameLib; + +namespace +{ + template + bool GetOrLoadInternal(const std::wstring& filePath, + Metadata& outMetadata, + Cache& cache, + Mutex& mutex, + const Loader& loader) + { + { + std::shared_lock sharedLock(mutex); + auto it = cache.find(filePath); + if (it != cache.end()) + { + // Return cached result (success or failure) + outMetadata = it->second.data; + return it->second.wasSuccessful; + } + } + + if (!loader) + { + // No loader provided + return false; + } + + Metadata loaded{}; + const bool result = loader(loaded); + + // Cache the result (success or failure) + { + std::unique_lock uniqueLock(mutex); + // Check if another thread cached it while we were loading + auto it = cache.find(filePath); + if (it == cache.end()) + { + // Not cached yet, insert our result + cache.emplace(filePath, CacheEntry{ result, loaded }); + } + else + { + // Another thread cached it, use their result + outMetadata = it->second.data; + return it->second.wasSuccessful; + } + } + + outMetadata = loaded; + return result; + } +} + +bool MetadataResultCache::GetOrLoadEXIF(const std::wstring& filePath, + EXIFMetadata& outMetadata, + const EXIFLoader& loader) +{ + return GetOrLoadInternal>(filePath, outMetadata, exifCache, exifMutex, loader); +} + +bool MetadataResultCache::GetOrLoadXMP(const std::wstring& filePath, + XMPMetadata& outMetadata, + const XMPLoader& loader) +{ + return GetOrLoadInternal>(filePath, outMetadata, xmpCache, xmpMutex, loader); +} + +void MetadataResultCache::ClearAll() +{ + { + std::unique_lock lock(exifMutex); + exifCache.clear(); + } + + { + std::unique_lock lock(xmpMutex); + xmpCache.clear(); + } +} diff --git a/src/modules/powerrename/lib/MetadataResultCache.h b/src/modules/powerrename/lib/MetadataResultCache.h new file mode 100644 index 0000000000..ad3b9782c4 --- /dev/null +++ b/src/modules/powerrename/lib/MetadataResultCache.h @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include "MetadataTypes.h" +#include +#include +#include +#include + +namespace PowerRenameLib +{ + class MetadataResultCache + { + public: + using EXIFLoader = std::function; + using XMPLoader = std::function; + + bool GetOrLoadEXIF(const std::wstring& filePath, EXIFMetadata& outMetadata, const EXIFLoader& loader); + bool GetOrLoadXMP(const std::wstring& filePath, XMPMetadata& outMetadata, const XMPLoader& loader); + + void ClearAll(); + + private: + // Wrapper to cache both success and failure states + template + struct CacheEntry + { + bool wasSuccessful; + T data; + }; + + mutable std::shared_mutex exifMutex; + mutable std::shared_mutex xmpMutex; + std::unordered_map> exifCache; + std::unordered_map> xmpCache; + }; +} diff --git a/src/modules/powerrename/lib/MetadataTypes.h b/src/modules/powerrename/lib/MetadataTypes.h new file mode 100644 index 0000000000..aa6a721e4c --- /dev/null +++ b/src/modules/powerrename/lib/MetadataTypes.h @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include +#include +#include +#include + +namespace PowerRenameLib +{ + /// + /// Supported metadata format types + /// + enum class MetadataType + { + EXIF, // EXIF metadata (camera settings, date taken, etc.) + XMP // XMP metadata (Dublin Core, Photoshop, etc.) + }; + + /// + /// Complete EXIF metadata structure + /// Contains all commonly used EXIF fields with optional values + /// + struct EXIFMetadata + { + // Date and time information + std::optional dateTaken; // DateTimeOriginal + std::optional dateDigitized; // DateTimeDigitized + std::optional dateModified; // DateTime + + // Camera information + std::optional cameraMake; // Make + std::optional cameraModel; // Model + std::optional lensModel; // LensModel + + // Shooting parameters + std::optional iso; // ISO speed + std::optional aperture; // F-number + std::optional shutterSpeed; // Exposure time + std::optional focalLength; // Focal length in mm + std::optional exposureBias; // Exposure bias value + std::optional flash; // Flash status + + // Image properties + std::optional width; // Image width in pixels + std::optional height; // Image height in pixels + std::optional orientation; // Image orientation + std::optional colorSpace; // Color space + + // Author and copyright + std::optional author; // Artist + std::optional copyright; // Copyright notice + + // GPS information + std::optional latitude; // GPS latitude in decimal degrees + std::optional longitude; // GPS longitude in decimal degrees + std::optional altitude; // GPS altitude in meters + }; + + /// + /// XMP (Extensible Metadata Platform) metadata structure + /// Contains XMP Basic, Dublin Core, Rights and Media Management schema fields + /// + struct XMPMetadata + { + // XMP Basic schema - https://ns.adobe.com/xap/1.0/ + std::optional createDate; // xmp:CreateDate + std::optional modifyDate; // xmp:ModifyDate + std::optional metadataDate; // xmp:MetadataDate + std::optional creatorTool; // xmp:CreatorTool + + // Dublin Core schema - http://purl.org/dc/elements/1.1/ + std::optional title; // dc:title + std::optional description; // dc:description + std::optional creator; // dc:creator (author) + std::optional> subject; // dc:subject (keywords) + + // XMP Rights Management schema - http://ns.adobe.com/xap/1.0/rights/ + std::optional rights; // xmpRights:WebStatement (copyright) + + // XMP Media Management schema - http://ns.adobe.com/xap/1.0/mm/ + std::optional documentID; // xmpMM:DocumentID + std::optional instanceID; // xmpMM:InstanceID + std::optional originalDocumentID; // xmpMM:OriginalDocumentID + std::optional versionID; // xmpMM:VersionID + }; + + + + + /// + /// Constants for metadata pattern names + /// + namespace MetadataPatterns + { + // EXIF patterns + constexpr wchar_t CAMERA_MAKE[] = L"CAMERA_MAKE"; + constexpr wchar_t CAMERA_MODEL[] = L"CAMERA_MODEL"; + constexpr wchar_t LENS[] = L"LENS"; + constexpr wchar_t ISO[] = L"ISO"; + constexpr wchar_t APERTURE[] = L"APERTURE"; + constexpr wchar_t SHUTTER[] = L"SHUTTER"; + constexpr wchar_t FOCAL[] = L"FOCAL"; + constexpr wchar_t FLASH[] = L"FLASH"; + constexpr wchar_t WIDTH[] = L"WIDTH"; + constexpr wchar_t HEIGHT[] = L"HEIGHT"; + constexpr wchar_t AUTHOR[] = L"AUTHOR"; + constexpr wchar_t COPYRIGHT[] = L"COPYRIGHT"; + constexpr wchar_t LATITUDE[] = L"LATITUDE"; + constexpr wchar_t LONGITUDE[] = L"LONGITUDE"; + + // Date components from EXIF DateTimeOriginal (when photo was taken) + constexpr wchar_t DATE_TAKEN_YYYY[] = L"DATE_TAKEN_YYYY"; + constexpr wchar_t DATE_TAKEN_YY[] = L"DATE_TAKEN_YY"; + constexpr wchar_t DATE_TAKEN_MM[] = L"DATE_TAKEN_MM"; + constexpr wchar_t DATE_TAKEN_DD[] = L"DATE_TAKEN_DD"; + constexpr wchar_t DATE_TAKEN_HH[] = L"DATE_TAKEN_HH"; + constexpr wchar_t DATE_TAKEN_mm[] = L"DATE_TAKEN_mm"; + constexpr wchar_t DATE_TAKEN_SS[] = L"DATE_TAKEN_SS"; + + // Additional EXIF patterns + constexpr wchar_t EXPOSURE_BIAS[] = L"EXPOSURE_BIAS"; + constexpr wchar_t ORIENTATION[] = L"ORIENTATION"; + constexpr wchar_t COLOR_SPACE[] = L"COLOR_SPACE"; + constexpr wchar_t ALTITUDE[] = L"ALTITUDE"; + + // XMP patterns + constexpr wchar_t CREATOR_TOOL[] = L"CREATOR_TOOL"; + + // Date components from XMP CreateDate + constexpr wchar_t CREATE_DATE_YYYY[] = L"CREATE_DATE_YYYY"; + constexpr wchar_t CREATE_DATE_YY[] = L"CREATE_DATE_YY"; + constexpr wchar_t CREATE_DATE_MM[] = L"CREATE_DATE_MM"; + constexpr wchar_t CREATE_DATE_DD[] = L"CREATE_DATE_DD"; + constexpr wchar_t CREATE_DATE_HH[] = L"CREATE_DATE_HH"; + constexpr wchar_t CREATE_DATE_mm[] = L"CREATE_DATE_mm"; + constexpr wchar_t CREATE_DATE_SS[] = L"CREATE_DATE_SS"; + + // Dublin Core patterns + constexpr wchar_t TITLE[] = L"TITLE"; + constexpr wchar_t DESCRIPTION[] = L"DESCRIPTION"; + constexpr wchar_t CREATOR[] = L"CREATOR"; + constexpr wchar_t SUBJECT[] = L"SUBJECT"; // Keywords + + // XMP Rights pattern + constexpr wchar_t RIGHTS[] = L"RIGHTS"; // Copyright + + // XMP Media Management patterns + constexpr wchar_t DOCUMENT_ID[] = L"DOCUMENT_ID"; + constexpr wchar_t INSTANCE_ID[] = L"INSTANCE_ID"; + constexpr wchar_t ORIGINAL_DOCUMENT_ID[] = L"ORIGINAL_DOCUMENT_ID"; + constexpr wchar_t VERSION_ID[] = L"VERSION_ID"; + } +} \ No newline at end of file diff --git a/src/modules/powerrename/lib/PowerRenameInterfaces.h b/src/modules/powerrename/lib/PowerRenameInterfaces.h index ea761d156a..7e3402433b 100644 --- a/src/modules/powerrename/lib/PowerRenameInterfaces.h +++ b/src/modules/powerrename/lib/PowerRenameInterfaces.h @@ -1,7 +1,10 @@ #pragma once #include "pch.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include #include +#include enum PowerRenameFlags { @@ -22,6 +25,9 @@ enum PowerRenameFlags CreationTime = 0x4000, ModificationTime = 0x8000, AccessTime = 0x10000, + // Metadata source flags + MetadataSourceEXIF = 0x20000, // Default + MetadataSourceXMP = 0x40000, }; enum PowerRenameFilters @@ -47,6 +53,7 @@ public: IFACEMETHOD(OnReplaceTermChanged)(_In_ PCWSTR replaceTerm) = 0; IFACEMETHOD(OnFlagsChanged)(_In_ DWORD flags) = 0; IFACEMETHOD(OnFileTimeChanged)(_In_ SYSTEMTIME fileTime) = 0; + IFACEMETHOD(OnMetadataChanged)() = 0; }; interface __declspec(uuid("E3ED45B5-9CE0-47E2-A595-67EB950B9B72")) IPowerRenameRegEx : public IUnknown @@ -62,6 +69,9 @@ public: IFACEMETHOD(PutFlags)(_In_ DWORD flags) = 0; IFACEMETHOD(PutFileTime)(_In_ SYSTEMTIME fileTime) = 0; IFACEMETHOD(ResetFileTime)() = 0; + IFACEMETHOD(PutMetadataPatterns)(_In_ const PowerRenameLib::MetadataPatternMap& patterns) = 0; + IFACEMETHOD(ResetMetadata)() = 0; + IFACEMETHOD(GetMetadataType)(_Out_ PowerRenameLib::MetadataType* metadataType) = 0; IFACEMETHOD(Replace)(_In_ PCWSTR source, _Outptr_ PWSTR* result, unsigned long& enumIndex) = 0; }; diff --git a/src/modules/powerrename/lib/PowerRenameItem.cpp b/src/modules/powerrename/lib/PowerRenameItem.cpp index b33fccfb89..61e07a93fc 100644 --- a/src/modules/powerrename/lib/PowerRenameItem.cpp +++ b/src/modules/powerrename/lib/PowerRenameItem.cpp @@ -74,7 +74,7 @@ IFACEMETHODIMP CPowerRenameItem::GetTime(_In_ DWORD flags, _Outptr_ SYSTEMTIME* else { // Default to modification time if no specific flag is set - parsedTimeType = PowerRenameFlags::CreationTime; + parsedTimeType = PowerRenameFlags::CreationTime; } if (m_isTimeParsed && parsedTimeType == m_parsedTimeType) @@ -86,6 +86,13 @@ IFACEMETHODIMP CPowerRenameItem::GetTime(_In_ DWORD flags, _Outptr_ SYSTEMTIME* HANDLE hFile = CreateFileW(m_path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); if (hFile != INVALID_HANDLE_VALUE) { + // Use RAII-style scope guard to ensure handle is always closed + struct FileHandleCloser + { + HANDLE handle; + ~FileHandleCloser() { if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); } + } scopedHandle{ hFile }; + FILETIME FileTime; bool success = false; @@ -122,8 +129,6 @@ IFACEMETHODIMP CPowerRenameItem::GetTime(_In_ DWORD flags, _Outptr_ SYSTEMTIME* } } } - - CloseHandle(hFile); } *time = m_time; return hr; diff --git a/src/modules/powerrename/lib/PowerRenameLib.vcxproj b/src/modules/powerrename/lib/PowerRenameLib.vcxproj index 103eab8e8e..bd5740dee7 100644 --- a/src/modules/powerrename/lib/PowerRenameLib.vcxproj +++ b/src/modules/powerrename/lib/PowerRenameLib.vcxproj @@ -16,19 +16,24 @@ - - - ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\ + $(ProjectDir)..\..\..\..\deps + + + Level3 WIN32;_LIB;%(PreprocessorDefinitions) $(ProjectDir)..\;$(ProjectDir)..\ui;$(ProjectDir)..\dll;$(ProjectDir)..\lib;$(ProjectDir)..\..\..\;$(ProjectDir)..\..\..\common\Telemetry;%(AdditionalIncludeDirectories);$(GeneratedFilesDir) + /FS %(AdditionalOptions) + + windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies) + @@ -47,6 +52,12 @@ + + + + + + @@ -64,6 +75,10 @@ Create + + + + diff --git a/src/modules/powerrename/lib/PowerRenameManager.cpp b/src/modules/powerrename/lib/PowerRenameManager.cpp index b6641374ba..160d064e09 100644 --- a/src/modules/powerrename/lib/PowerRenameManager.cpp +++ b/src/modules/powerrename/lib/PowerRenameManager.cpp @@ -462,6 +462,12 @@ IFACEMETHODIMP CPowerRenameManager::OnFileTimeChanged(_In_ SYSTEMTIME /*fileTime return S_OK; } +IFACEMETHODIMP CPowerRenameManager::OnMetadataChanged() +{ + _PerformRegExRename(); + return S_OK; +} + HRESULT CPowerRenameManager::s_CreateInstance(_Outptr_ IPowerRenameManager** ppsrm) { *ppsrm = nullptr; diff --git a/src/modules/powerrename/lib/PowerRenameManager.h b/src/modules/powerrename/lib/PowerRenameManager.h index f339f5c9d4..a9fa44d144 100644 --- a/src/modules/powerrename/lib/PowerRenameManager.h +++ b/src/modules/powerrename/lib/PowerRenameManager.h @@ -50,6 +50,7 @@ public: IFACEMETHODIMP OnReplaceTermChanged(_In_ PCWSTR replaceTerm); IFACEMETHODIMP OnFlagsChanged(_In_ DWORD flags); IFACEMETHODIMP OnFileTimeChanged(_In_ SYSTEMTIME fileTime); + IFACEMETHODIMP OnMetadataChanged(); static HRESULT s_CreateInstance(_Outptr_ IPowerRenameManager** ppsrm); diff --git a/src/modules/powerrename/lib/PowerRenameRegEx.cpp b/src/modules/powerrename/lib/PowerRenameRegEx.cpp index 76136ff39c..567df48606 100644 --- a/src/modules/powerrename/lib/PowerRenameRegEx.cpp +++ b/src/modules/powerrename/lib/PowerRenameRegEx.cpp @@ -154,8 +154,7 @@ HRESULT CPowerRenameRegEx::_OnEnumerateOrRandomizeItemsChanged() std::find_if( m_randomizer.begin(), m_randomizer.end(), - [option](const Randomizer& r) -> bool { return r.options.replaceStrSpan.offset == option.replaceStrSpan.offset; } - )) + [option](const Randomizer& r) -> bool { return r.options.replaceStrSpan.offset == option.replaceStrSpan.offset; })) { // Only add as enumerator if we didn't find a randomizer already at this offset. // Every randomizer will also be a valid enumerator according to the definition of enumerators, which allows any string to mean the default enumerator, so it should be interpreted that the user wanted a randomizer if both were found at the same offset of the replace string. @@ -329,6 +328,22 @@ IFACEMETHODIMP CPowerRenameRegEx::ResetFileTime() return S_OK; } +IFACEMETHODIMP CPowerRenameRegEx::PutMetadataPatterns(_In_ const PowerRenameLib::MetadataPatternMap& patterns) +{ + m_metadataPatterns = patterns; + m_useMetadata = true; + _OnMetadataChanged(); + return S_OK; +} + +IFACEMETHODIMP CPowerRenameRegEx::ResetMetadata() +{ + m_metadataPatterns.clear(); + m_useMetadata = false; + _OnMetadataChanged(); + return S_OK; +} + HRESULT CPowerRenameRegEx::s_CreateInstance(_Outptr_ IPowerRenameRegEx** renameRegEx) { *renameRegEx = nullptr; @@ -388,24 +403,50 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u // TODO: creating the regex could be costly. May want to cache this. wchar_t newReplaceTerm[MAX_PATH] = { 0 }; bool fileTimeErrorOccurred = false; + bool metadataErrorOccurred = false; + bool appliedTemplateTransform = false; + + std::wstring replaceTemplate; + if (m_replaceTerm) + { + replaceTemplate = m_replaceTerm; + } + if (m_useFileTime) { - if (FAILED(GetDatedFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), m_replaceTerm, m_fileTime))) + if (FAILED(GetDatedFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), replaceTemplate.c_str(), m_fileTime))) + { fileTimeErrorOccurred = true; + } + else + { + replaceTemplate.assign(newReplaceTerm); + appliedTemplateTransform = true; + } + } + + if (m_useMetadata) + { + if (FAILED(GetMetadataFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), replaceTemplate.c_str(), m_metadataPatterns))) + { + metadataErrorOccurred = true; + } + else + { + replaceTemplate.assign(newReplaceTerm); + appliedTemplateTransform = true; + } } std::wstring sourceToUse; - std::wstring originalSource; sourceToUse.reserve(MAX_PATH); - originalSource.reserve(MAX_PATH); sourceToUse = source; - originalSource = sourceToUse; std::wstring searchTerm(m_searchTerm); std::wstring replaceTerm; - if (m_useFileTime && !fileTimeErrorOccurred) + if (appliedTemplateTransform) { - replaceTerm = newReplaceTerm; + replaceTerm = replaceTemplate; } else if (m_replaceTerm) { @@ -487,27 +528,46 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u } } - bool replacedSomething = false; + bool shouldIncrementCounter = false; + const bool isCaseInsensitive = !(m_flags & CaseSensitive); + if (m_flags & UseRegularExpressions) { replaceTerm = regex_replace(replaceTerm, zeroGroupRegex, L"$1$$$0"); replaceTerm = regex_replace(replaceTerm, otherGroupsRegex, L"$1$0$4"); - res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, !(m_flags & CaseSensitive)); - replacedSomething = originalSource != res; + res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, isCaseInsensitive); + + // Use regex search to determine if a match exists. This is the basis for incrementing + // the counter. + if (_useBoostLib) + { + boost::wregex pattern(m_searchTerm, boost::wregex::ECMAScript | (isCaseInsensitive ? boost::wregex::icase : boost::wregex::normal)); + shouldIncrementCounter = boost::regex_search(sourceToUse, pattern); + } + else + { + auto regexFlags = std::wregex::ECMAScript; + if (isCaseInsensitive) + { + regexFlags |= std::wregex::icase; + } + std::wregex pattern(m_searchTerm, regexFlags); + shouldIncrementCounter = std::regex_search(sourceToUse, pattern); + } } else { - // Simple search and replace + // Simple search and replace. size_t pos = 0; do { - pos = _Find(sourceToUse, searchTerm, (!(m_flags & CaseSensitive)), pos); + pos = _Find(sourceToUse, searchTerm, isCaseInsensitive, pos); if (pos != std::string::npos) { res = sourceToUse.replace(pos, searchTerm.length(), replaceTerm); pos += replaceTerm.length(); - replacedSomething = true; + shouldIncrementCounter = true; } if (!(m_flags & MatchAllOccurrences)) { @@ -516,7 +576,8 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u } while (pos != std::string::npos); } hr = SHStrDup(res.c_str(), result); - if (replacedSomething) + + if (shouldIncrementCounter) enumIndex++; } catch (regex_error e) @@ -590,3 +651,43 @@ void CPowerRenameRegEx::_OnFileTimeChanged() } } } + +void CPowerRenameRegEx::_OnMetadataChanged() +{ + CSRWSharedAutoLock lock(&m_lockEvents); + + for (auto it : m_renameRegExEvents) + { + if (it.pEvents) + { + it.pEvents->OnMetadataChanged(); + } + } +} + +PowerRenameLib::MetadataType CPowerRenameRegEx::_GetMetadataTypeFromFlags() const +{ + if (m_flags & MetadataSourceXMP) + return PowerRenameLib::MetadataType::XMP; + + // Default to EXIF + return PowerRenameLib::MetadataType::EXIF; +} + +// Interface method implementation +IFACEMETHODIMP CPowerRenameRegEx::GetMetadataType(_Out_ PowerRenameLib::MetadataType* metadataType) +{ + if (metadataType == nullptr) + return E_POINTER; + + *metadataType = _GetMetadataTypeFromFlags(); + return S_OK; +} + +// Convenience method for internal use +PowerRenameLib::MetadataType CPowerRenameRegEx::GetMetadataType() const +{ + return _GetMetadataTypeFromFlags(); +} + + diff --git a/src/modules/powerrename/lib/PowerRenameRegEx.h b/src/modules/powerrename/lib/PowerRenameRegEx.h index 55c6c14c17..9e43107efa 100644 --- a/src/modules/powerrename/lib/PowerRenameRegEx.h +++ b/src/modules/powerrename/lib/PowerRenameRegEx.h @@ -5,6 +5,8 @@ #include "Enumerating.h" #include "Randomizer.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include "PowerRenameInterfaces.h" @@ -29,7 +31,13 @@ public: IFACEMETHODIMP PutFlags(_In_ DWORD flags); IFACEMETHODIMP PutFileTime(_In_ SYSTEMTIME fileTime); IFACEMETHODIMP ResetFileTime(); + IFACEMETHODIMP PutMetadataPatterns(_In_ const PowerRenameLib::MetadataPatternMap& patterns); + IFACEMETHODIMP ResetMetadata(); + IFACEMETHODIMP GetMetadataType(_Out_ PowerRenameLib::MetadataType* metadataType); IFACEMETHODIMP Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, unsigned long& enumIndex); + + // Get current metadata type based on flags + PowerRenameLib::MetadataType GetMetadataType() const; static HRESULT s_CreateInstance(_Outptr_ IPowerRenameRegEx** renameRegEx); @@ -41,7 +49,9 @@ protected: void _OnReplaceTermChanged(); void _OnFlagsChanged(); void _OnFileTimeChanged(); + void _OnMetadataChanged(); HRESULT _OnEnumerateOrRandomizeItemsChanged(); + PowerRenameLib::MetadataType _GetMetadataTypeFromFlags() const; size_t _Find(std::wstring data, std::wstring toSearch, bool caseInsensitive, size_t pos); @@ -54,6 +64,9 @@ protected: SYSTEMTIME m_fileTime = { 0 }; bool m_useFileTime = false; + PowerRenameLib::MetadataPatternMap m_metadataPatterns; + bool m_useMetadata = false; + CSRWLock m_lock; CSRWLock m_lockEvents; diff --git a/src/modules/powerrename/lib/PropVariantValue.h b/src/modules/powerrename/lib/PropVariantValue.h new file mode 100644 index 0000000000..23e5973d25 --- /dev/null +++ b/src/modules/powerrename/lib/PropVariantValue.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +namespace PowerRenameLib +{ + /// + /// RAII wrapper around PROPVARIANT to ensure proper initialization and cleanup. + /// Move-only semantics keep ownership simple while still allowing use in optionals. + /// + struct PropVariantValue + { + PropVariantValue() noexcept + { + PropVariantInit(&value); + } + + ~PropVariantValue() + { + PropVariantClear(&value); + } + + PropVariantValue(const PropVariantValue&) = delete; + PropVariantValue& operator=(const PropVariantValue&) = delete; + + PropVariantValue(PropVariantValue&& other) noexcept + { + value = other.value; + PropVariantInit(&other.value); // Properly clear the moved-from object + } + + PropVariantValue& operator=(PropVariantValue&& other) noexcept + { + if (this != &other) + { + PropVariantClear(&value); + value = other.value; + PropVariantInit(&other.value); // Properly clear the moved-from object + } + return *this; + } + + PROPVARIANT* GetAddressOf() noexcept + { + return &value; + } + + PROPVARIANT& Get() noexcept + { + return value; + } + + const PROPVARIANT& Get() const noexcept + { + return value; + } + + private: + PROPVARIANT value; + }; +} diff --git a/src/modules/powerrename/lib/Renaming.cpp b/src/modules/powerrename/lib/Renaming.cpp index aa21666783..028621eef4 100644 --- a/src/modules/powerrename/lib/Renaming.cpp +++ b/src/modules/powerrename/lib/Renaming.cpp @@ -1,9 +1,13 @@ #include "pch.h" #include +#include +#include +#include #include "Renaming.h" #include - +#include "MetadataPatternExtractor.h" +#include "PowerRenameRegEx.h" namespace fs = std::filesystem; bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnumIndex, CComPtr& spItem) @@ -14,6 +18,7 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum PWSTR replaceTerm = nullptr; bool useFileTime = false; + bool useMetadata = false; winrt::check_hresult(spRenameRegEx->GetReplaceTerm(&replaceTerm)); @@ -21,7 +26,6 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum { useFileTime = true; } - CoTaskMemFree(replaceTerm); int id = -1; winrt::check_hresult(spItem->GetId(&id)); @@ -30,6 +34,29 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum bool isSubFolderContent = false; winrt::check_hresult(spItem->GetIsFolder(&isFolder)); winrt::check_hresult(spItem->GetIsSubFolderContent(&isSubFolderContent)); + + // Get metadata type to check if metadata patterns are used + PowerRenameLib::MetadataType metadataType; + HRESULT hr = spRenameRegEx->GetMetadataType(&metadataType); + if (FAILED(hr)) + { + // Fallback to default metadata type if call fails + metadataType = PowerRenameLib::MetadataType::EXIF; + } + + // Check if metadata is used AND if this file type supports metadata + // Get file path early for metadata type checking and reuse later + PWSTR filePath = nullptr; + winrt::check_hresult(spItem->GetPath(&filePath)); + std::wstring filePathStr(filePath); // Copy once for reuse + CoTaskMemFree(filePath); // Free immediately after copying + + if (isMetadataUsed(replaceTerm, metadataType, filePathStr.c_str(), isFolder)) + { + useMetadata = true; + } + + CoTaskMemFree(replaceTerm); if ((isFolder && (flags & PowerRenameFlags::ExcludeFolders)) || (!isFolder && (flags & PowerRenameFlags::ExcludeFiles)) || (isSubFolderContent && (flags & PowerRenameFlags::ExcludeSubfolders)) || @@ -82,6 +109,53 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum winrt::check_hresult(spRenameRegEx->PutFileTime(fileTime)); } + if (useMetadata) + { + // Extract metadata patterns from the file + // Note: filePathStr was already obtained and saved earlier for reuse + + // Get metadata type using the interface method + PowerRenameLib::MetadataType metadataType; + HRESULT hr = spRenameRegEx->GetMetadataType(&metadataType); + if (FAILED(hr)) + { + // Fallback to default metadata type if call fails + metadataType = PowerRenameLib::MetadataType::EXIF; + } + // Extract all patterns for the selected metadata type + // At this point we know the file is a supported image format (jpg/jpeg/png/tif/tiff) + static std::mutex s_metadataMutex; // Mutex to protect static variables + static std::once_flag s_metadataExtractorInitFlag; + static std::shared_ptr s_metadataExtractor; + static std::optional s_activeMetadataType; + + // Initialize the extractor only once + std::call_once(s_metadataExtractorInitFlag, []() { + s_metadataExtractor = std::make_shared(); + }); + + // Protect access to shared state + { + std::lock_guard lock(s_metadataMutex); + + // Clear cache if metadata type has changed + if (s_activeMetadataType.has_value() && s_activeMetadataType.value() != metadataType) + { + s_metadataExtractor->ClearCache(); + } + + // Update the active metadata type + s_activeMetadataType = metadataType; + } + + // Extract patterns (this can be done outside the lock if ExtractPatterns is thread-safe) + PowerRenameLib::MetadataPatternMap patterns = s_metadataExtractor->ExtractPatterns(filePathStr, metadataType); + + // Always call PutMetadataPatterns to ensure all patterns get replaced + // Even if empty, this keeps metadata placeholders consistent when no values are extracted + winrt::check_hresult(spRenameRegEx->PutMetadataPatterns(patterns)); + } + PWSTR newName = nullptr; // Failure here means we didn't match anything or had nothing to match @@ -93,6 +167,10 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum winrt::check_hresult(spRenameRegEx->ResetFileTime()); } + if (useMetadata) + { + winrt::check_hresult(spRenameRegEx->ResetMetadata()); + } wchar_t resultName[MAX_PATH] = { 0 }; PWSTR newNameToUse = nullptr; @@ -206,4 +284,4 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum CoTaskMemFree(originalName); return wouldRename; -} \ No newline at end of file +} diff --git a/src/modules/powerrename/lib/WICMetadataExtractor.cpp b/src/modules/powerrename/lib/WICMetadataExtractor.cpp new file mode 100644 index 0000000000..bd2f9c08dc --- /dev/null +++ b/src/modules/powerrename/lib/WICMetadataExtractor.cpp @@ -0,0 +1,1021 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "WICMetadataExtractor.h" +#include "MetadataFormatHelper.h" +#include +#include +#include +#include +#include +#include + +using namespace PowerRenameLib; + +namespace +{ + // Documentation: https://learn.microsoft.com/en-us/windows/win32/wic/-wic-native-image-format-metadata-queries + + // WIC metadata property paths + const std::wstring EXIF_DATE_TAKEN = L"/app1/ifd/exif/{ushort=36867}"; // DateTimeOriginal + const std::wstring EXIF_DATE_DIGITIZED = L"/app1/ifd/exif/{ushort=36868}"; // DateTimeDigitized + const std::wstring EXIF_DATE_MODIFIED = L"/app1/ifd/{ushort=306}"; // DateTime + const std::wstring EXIF_CAMERA_MAKE = L"/app1/ifd/{ushort=271}"; // Make + const std::wstring EXIF_CAMERA_MODEL = L"/app1/ifd/{ushort=272}"; // Model + const std::wstring EXIF_LENS_MODEL = L"/app1/ifd/exif/{ushort=42036}"; // LensModel + const std::wstring EXIF_ISO = L"/app1/ifd/exif/{ushort=34855}"; // ISOSpeedRatings + const std::wstring EXIF_APERTURE = L"/app1/ifd/exif/{ushort=33437}"; // FNumber + const std::wstring EXIF_SHUTTER_SPEED = L"/app1/ifd/exif/{ushort=33434}"; // ExposureTime + const std::wstring EXIF_FOCAL_LENGTH = L"/app1/ifd/exif/{ushort=37386}"; // FocalLength + const std::wstring EXIF_EXPOSURE_BIAS = L"/app1/ifd/exif/{ushort=37380}"; // ExposureBiasValue + const std::wstring EXIF_FLASH = L"/app1/ifd/exif/{ushort=37385}"; // Flash + const std::wstring EXIF_ORIENTATION = L"/app1/ifd/{ushort=274}"; // Orientation + const std::wstring EXIF_COLOR_SPACE = L"/app1/ifd/exif/{ushort=40961}"; // ColorSpace + const std::wstring EXIF_WIDTH = L"/app1/ifd/exif/{ushort=40962}"; // PixelXDimension - actual image width + const std::wstring EXIF_HEIGHT = L"/app1/ifd/exif/{ushort=40963}"; // PixelYDimension - actual image height + const std::wstring EXIF_ARTIST = L"/app1/ifd/{ushort=315}"; // Artist + const std::wstring EXIF_COPYRIGHT = L"/app1/ifd/{ushort=33432}"; // Copyright + + // GPS paths + const std::wstring GPS_LATITUDE = L"/app1/ifd/gps/{ushort=2}"; // GPSLatitude + const std::wstring GPS_LATITUDE_REF = L"/app1/ifd/gps/{ushort=1}"; // GPSLatitudeRef + const std::wstring GPS_LONGITUDE = L"/app1/ifd/gps/{ushort=4}"; // GPSLongitude + const std::wstring GPS_LONGITUDE_REF = L"/app1/ifd/gps/{ushort=3}"; // GPSLongitudeRef + const std::wstring GPS_ALTITUDE = L"/app1/ifd/gps/{ushort=6}"; // GPSAltitude + const std::wstring GPS_ALTITUDE_REF = L"/app1/ifd/gps/{ushort=5}"; // GPSAltitudeRef + + + // Documentation: https://developer.adobe.com/xmp/docs/XMPNamespaces/xmp/ + // Based on actual WIC path format discovered through enumeration + // XMP Basic schema - xmp: namespace + const std::wstring XMP_CREATE_DATE = L"/xmp/xmp:CreateDate"; // XMP Create Date + const std::wstring XMP_MODIFY_DATE = L"/xmp/xmp:ModifyDate"; // XMP Modify Date + const std::wstring XMP_METADATA_DATE = L"/xmp/xmp:MetadataDate"; // XMP Metadata Date + const std::wstring XMP_CREATOR_TOOL = L"/xmp/xmp:CreatorTool"; // XMP Creator Tool + + // Dublin Core schema - dc: namespace + // Note: For language alternatives like title/description, we need to append /x-default + const std::wstring XMP_DC_TITLE = L"/xmp/dc:title/x-default"; // Title (default language) + const std::wstring XMP_DC_DESCRIPTION = L"/xmp/dc:description/x-default"; // Description (default language) + const std::wstring XMP_DC_CREATOR = L"/xmp/dc:creator"; // Creator/Author + const std::wstring XMP_DC_SUBJECT = L"/xmp/dc:subject"; // Subject/Keywords (array) + + // XMP Rights Management schema - xmpRights: namespace + const std::wstring XMP_RIGHTS = L"/xmp/xmpRights:WebStatement"; // Copyright/Rights + + // XMP Media Management schema - xmpMM: namespace + const std::wstring XMP_MM_DOCUMENT_ID = L"/xmp/xmpMM:DocumentID"; // Document ID + const std::wstring XMP_MM_INSTANCE_ID = L"/xmp/xmpMM:InstanceID"; // Instance ID + const std::wstring XMP_MM_ORIGINAL_DOCUMENT_ID = L"/xmp/xmpMM:OriginalDocumentID"; // Original Document ID + const std::wstring XMP_MM_VERSION_ID = L"/xmp/xmpMM:VersionID"; // Version ID + + + std::wstring TrimWhitespace(const std::wstring& value) + { + const auto first = value.find_first_not_of(L" \t\r\n"); + if (first == std::wstring::npos) + { + return {}; + } + + const auto last = value.find_last_not_of(L" \t\r\n"); + return value.substr(first, last - first + 1); + } + + bool TryParseFixedWidthInt(const std::wstring& source, size_t start, size_t length, int& value) + { + if (start + length > source.size()) + { + return false; + } + + int result = 0; + for (size_t i = 0; i < length; ++i) + { + const wchar_t ch = source[start + i]; + if (ch < L'0' || ch > L'9') + { + return false; + } + + result = result * 10 + static_cast(ch - L'0'); + } + + value = result; + return true; + } + + bool ValidateAndBuildSystemTime(int year, int month, int day, int hour, int minute, int second, int milliseconds, SYSTEMTIME& outTime) + { + if (year < 1601 || year > 9999 || + month < 1 || month > 12 || + day < 1 || day > 31 || + hour < 0 || hour > 23 || + minute < 0 || minute > 59 || + second < 0 || second > 59 || + milliseconds < 0 || milliseconds > 999) + { + return false; + } + + SYSTEMTIME candidate{}; + candidate.wYear = static_cast(year); + candidate.wMonth = static_cast(month); + candidate.wDay = static_cast(day); + candidate.wHour = static_cast(hour); + candidate.wMinute = static_cast(minute); + candidate.wSecond = static_cast(second); + candidate.wMilliseconds = static_cast(milliseconds); + + FILETIME fileTime{}; + if (!SystemTimeToFileTime(&candidate, &fileTime)) + { + return false; + } + + outTime = candidate; + return true; + } + + std::optional ParseExifDateTime(const std::wstring& date) + { + if (date.size() < 19) + { + return std::nullopt; + } + + if (date[4] != L':' || date[7] != L':' || + (date[10] != L' ' && date[10] != L'T') || + date[13] != L':' || date[16] != L':') + { + return std::nullopt; + } + + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int minute = 0; + int second = 0; + + if (!TryParseFixedWidthInt(date, 0, 4, year) || + !TryParseFixedWidthInt(date, 5, 2, month) || + !TryParseFixedWidthInt(date, 8, 2, day) || + !TryParseFixedWidthInt(date, 11, 2, hour) || + !TryParseFixedWidthInt(date, 14, 2, minute) || + !TryParseFixedWidthInt(date, 17, 2, second)) + { + return std::nullopt; + } + + int milliseconds = 0; + size_t pos = 19; + if (pos < date.size() && (date[pos] == L'.' || date[pos] == L',')) + { + ++pos; + int digits = 0; + while (pos < date.size() && std::iswdigit(date[pos]) && digits < 3) + { + milliseconds = milliseconds * 10 + static_cast(date[pos] - L'0'); + ++pos; + ++digits; + } + + while (digits > 0 && digits < 3) + { + milliseconds *= 10; + ++digits; + } + } + + SYSTEMTIME result{}; + if (!ValidateAndBuildSystemTime(year, month, day, hour, minute, second, milliseconds, result)) + { + return std::nullopt; + } + + return result; + } + + std::optional ParseIso8601DateTime(const std::wstring& date) + { + if (date.size() < 19) + { + return std::nullopt; + } + + size_t separator = date.find(L'T'); + if (separator == std::wstring::npos) + { + separator = date.find(L' '); + } + + if (separator == std::wstring::npos) + { + return std::nullopt; + } + + int year = 0; + int month = 0; + int day = 0; + if (!TryParseFixedWidthInt(date, 0, 4, year) || + date[4] != L'-' || + !TryParseFixedWidthInt(date, 5, 2, month) || + date[7] != L'-' || + !TryParseFixedWidthInt(date, 8, 2, day)) + { + return std::nullopt; + } + + size_t timePos = separator + 1; + if (timePos + 7 >= date.size()) + { + return std::nullopt; + } + + int hour = 0; + int minute = 0; + int second = 0; + if (!TryParseFixedWidthInt(date, timePos, 2, hour) || + date[timePos + 2] != L':' || + !TryParseFixedWidthInt(date, timePos + 3, 2, minute) || + date[timePos + 5] != L':' || + !TryParseFixedWidthInt(date, timePos + 6, 2, second)) + { + return std::nullopt; + } + + size_t pos = timePos + 8; + int milliseconds = 0; + if (pos < date.size() && (date[pos] == L'.' || date[pos] == L',')) + { + ++pos; + int digits = 0; + while (pos < date.size() && std::iswdigit(date[pos]) && digits < 3) + { + milliseconds = milliseconds * 10 + static_cast(date[pos] - L'0'); + ++pos; + ++digits; + } + + while (pos < date.size() && std::iswdigit(date[pos])) + { + ++pos; + } + + while (digits > 0 && digits < 3) + { + milliseconds *= 10; + ++digits; + } + } + + bool hasOffset = false; + int offsetMinutes = 0; + if (pos < date.size()) + { + const wchar_t tzIndicator = date[pos]; + if (tzIndicator == L'Z' || tzIndicator == L'z') + { + hasOffset = true; + offsetMinutes = 0; + ++pos; + } + else if (tzIndicator == L'+' || tzIndicator == L'-') + { + hasOffset = true; + const int sign = (tzIndicator == L'-') ? -1 : 1; + ++pos; + + int offsetHours = 0; + int offsetMins = 0; + if (!TryParseFixedWidthInt(date, pos, 2, offsetHours)) + { + return std::nullopt; + } + pos += 2; + + if (pos < date.size() && date[pos] == L':') + { + ++pos; + } + + if (pos + 1 < date.size() && std::iswdigit(date[pos]) && std::iswdigit(date[pos + 1])) + { + if (!TryParseFixedWidthInt(date, pos, 2, offsetMins)) + { + return std::nullopt; + } + pos += 2; + } + + if (offsetHours < 0 || offsetHours > 23 || offsetMins < 0 || offsetMins > 59) + { + return std::nullopt; + } + + offsetMinutes = sign * (offsetHours * 60 + offsetMins); + } + + while (pos < date.size() && std::iswspace(date[pos])) + { + ++pos; + } + + if (pos != date.size()) + { + return std::nullopt; + } + } + + SYSTEMTIME baseTime{}; + if (!ValidateAndBuildSystemTime(year, month, day, hour, minute, second, milliseconds, baseTime)) + { + return std::nullopt; + } + + if (!hasOffset) + { + return baseTime; + } + + FILETIME utcFileTime{}; + if (!SystemTimeToFileTime(&baseTime, &utcFileTime)) + { + return std::nullopt; + } + + ULARGE_INTEGER timeValue{}; + timeValue.LowPart = utcFileTime.dwLowDateTime; + timeValue.HighPart = utcFileTime.dwHighDateTime; + + constexpr long long TicksPerMinute = 60LL * 10000000LL; + timeValue.QuadPart -= static_cast(offsetMinutes) * TicksPerMinute; + + FILETIME adjustedUtc{}; + adjustedUtc.dwLowDateTime = timeValue.LowPart; + adjustedUtc.dwHighDateTime = timeValue.HighPart; + + FILETIME localFileTime{}; + if (!FileTimeToLocalFileTime(&adjustedUtc, &localFileTime)) + { + return std::nullopt; + } + + SYSTEMTIME localTime{}; + if (!FileTimeToSystemTime(&localFileTime, &localTime)) + { + return std::nullopt; + } + + return localTime; + } +// Global WIC factory management with thread-safe access + CComPtr g_wicFactory; + std::once_flag g_wicInitFlag; + std::mutex g_wicFactoryMutex; // Protect access to g_wicFactory +} + +WICMetadataExtractor::WICMetadataExtractor() +{ + InitializeWIC(); +} + +WICMetadataExtractor::~WICMetadataExtractor() +{ + // WIC cleanup handled statically +} + +void WICMetadataExtractor::InitializeWIC() +{ + std::call_once(g_wicInitFlag, []() { + // Don't initialize COM in library code - assume caller has done it + // Just create the WIC factory + HRESULT hr = CoCreateInstance( + CLSID_WICImagingFactory, + nullptr, + CLSCTX_INPROC_SERVER, + IID_IWICImagingFactory, + reinterpret_cast(&g_wicFactory) + ); + + if (FAILED(hr)) + { + g_wicFactory = nullptr; + } + }); +} + +CComPtr WICMetadataExtractor::GetWICFactory() +{ + std::lock_guard lock(g_wicFactoryMutex); + return g_wicFactory; +} + +bool WICMetadataExtractor::ExtractEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata) +{ + return cache.GetOrLoadEXIF(filePath, outMetadata, [this, &filePath](EXIFMetadata& metadata) { + return LoadEXIFMetadata(filePath, metadata); + }); +} + +bool WICMetadataExtractor::LoadEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata) +{ + CComPtr reader; + + if (!PathFileExistsW(filePath.c_str())) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction failed: File not found - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + auto decoder = CreateDecoder(filePath); + if (!decoder) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction: Unsupported format or unable to create decoder - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction failed: WIC decoder error - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + reader = GetMetadataReader(decoder); + if (!reader) + { + // No metadata is not necessarily an error - just means the file has no EXIF data + return false; + } + + ExtractAllEXIFFields(reader, outMetadata); + ExtractGPSData(reader, outMetadata); + + return true; +} + +void WICMetadataExtractor::ClearCache() +{ + cache.ClearAll(); +} + +CComPtr WICMetadataExtractor::CreateDecoder(const std::wstring& filePath) +{ + auto factory = GetWICFactory(); + if (!factory) + { + return nullptr; + } + + CComPtr decoder; + HRESULT hr = factory->CreateDecoderFromFilename( + filePath.c_str(), + nullptr, + GENERIC_READ, + WICDecodeMetadataCacheOnLoad, + &decoder + ); + + if (FAILED(hr)) + { + return nullptr; + } + + return decoder; +} + +CComPtr WICMetadataExtractor::GetMetadataReader(IWICBitmapDecoder* decoder) +{ + if (!decoder) + { + return nullptr; + } + + CComPtr frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { + return nullptr; + } + + CComPtr reader; + frame->GetMetadataQueryReader(&reader); + + return reader; +} + +void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata) +{ + if (!reader) + return; + + // Extract date/time fields + metadata.dateTaken = ReadDateTime(reader, EXIF_DATE_TAKEN); + metadata.dateDigitized = ReadDateTime(reader, EXIF_DATE_DIGITIZED); + metadata.dateModified = ReadDateTime(reader, EXIF_DATE_MODIFIED); + + // Extract camera information + metadata.cameraMake = ReadString(reader, EXIF_CAMERA_MAKE); + metadata.cameraModel = ReadString(reader, EXIF_CAMERA_MODEL); + metadata.lensModel = ReadString(reader, EXIF_LENS_MODEL); + + // Extract shooting parameters + metadata.iso = ReadInteger(reader, EXIF_ISO); + metadata.aperture = ReadDouble(reader, EXIF_APERTURE); + metadata.shutterSpeed = ReadDouble(reader, EXIF_SHUTTER_SPEED); + metadata.focalLength = ReadDouble(reader, EXIF_FOCAL_LENGTH); + metadata.exposureBias = ReadDouble(reader, EXIF_EXPOSURE_BIAS); + metadata.flash = ReadInteger(reader, EXIF_FLASH); + + // Extract image properties + metadata.width = ReadInteger(reader, EXIF_WIDTH); + metadata.height = ReadInteger(reader, EXIF_HEIGHT); + metadata.orientation = ReadInteger(reader, EXIF_ORIENTATION); + metadata.colorSpace = ReadInteger(reader, EXIF_COLOR_SPACE); + + // Extract author information + metadata.author = ReadString(reader, EXIF_ARTIST); + metadata.copyright = ReadString(reader, EXIF_COPYRIGHT); +} + +void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata) +{ + if (!reader) + { + return; + } + + auto lat = ReadMetadata(reader, GPS_LATITUDE); + auto lon = ReadMetadata(reader, GPS_LONGITUDE); + auto latRef = ReadMetadata(reader, GPS_LATITUDE_REF); + auto lonRef = ReadMetadata(reader, GPS_LONGITUDE_REF); + + if (lat && lon) + { + PropVariantValue emptyLatRef; + PropVariantValue emptyLonRef; + + const PROPVARIANT& latRefVar = latRef ? latRef->Get() : emptyLatRef.Get(); + const PROPVARIANT& lonRefVar = lonRef ? lonRef->Get() : emptyLonRef.Get(); + + auto coords = MetadataFormatHelper::ParseGPSCoordinates( + lat->Get(), + lon->Get(), + latRefVar, + lonRefVar); + + metadata.latitude = coords.first; + metadata.longitude = coords.second; + } + + auto alt = ReadMetadata(reader, GPS_ALTITUDE); + if (alt) + { + metadata.altitude = MetadataFormatHelper::ParseGPSRational(alt->Get()); + } +} + + +std::optional WICMetadataExtractor::ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar) + { + return std::nullopt; + } + + std::wstring rawValue; + const PROPVARIANT& variant = propVar->Get(); + + switch (variant.vt) + { + case VT_LPWSTR: + if (variant.pwszVal) + { + rawValue = variant.pwszVal; + } + break; + case VT_BSTR: + if (variant.bstrVal) + { + rawValue = variant.bstrVal; + } + break; + case VT_LPSTR: + if (variant.pszVal) + { + const int size = MultiByteToWideChar(CP_UTF8, 0, variant.pszVal, -1, nullptr, 0); + if (size > 1) + { + rawValue.resize(static_cast(size) - 1); + MultiByteToWideChar(CP_UTF8, 0, variant.pszVal, -1, &rawValue[0], size); + } + } + break; + default: + break; + } + + if (rawValue.empty()) + { + return std::nullopt; + } + + const std::wstring normalized = TrimWhitespace(rawValue); + if (normalized.empty()) + { + return std::nullopt; + } + + if (auto exifDate = ParseExifDateTime(normalized)) + { + return exifDate; + } + + if (auto isoDate = ParseIso8601DateTime(normalized)) + { + return isoDate; + } + + return std::nullopt; +} + +std::optional WICMetadataExtractor::ReadString(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + std::wstring result; + switch (propVar->Get().vt) + { + case VT_LPWSTR: + if (propVar->Get().pwszVal) + result = propVar->Get().pwszVal; + break; + case VT_BSTR: + if (propVar->Get().bstrVal) + result = propVar->Get().bstrVal; + break; + case VT_LPSTR: + if (propVar->Get().pszVal) + { + int size = MultiByteToWideChar(CP_UTF8, 0, propVar->Get().pszVal, -1, nullptr, 0); + if (size > 1) + { + result.resize(static_cast(size) - 1); + MultiByteToWideChar(CP_UTF8, 0, propVar->Get().pszVal, -1, &result[0], size); + } + } + break; + } + + + // Trim whitespace from both ends + if (!result.empty()) + { + size_t start = result.find_first_not_of(L" \t\r\n"); + size_t end = result.find_last_not_of(L" \t\r\n"); + if (start != std::wstring::npos && end != std::wstring::npos) + { + result = result.substr(start, end - start + 1); + } + else if (start == std::wstring::npos) + { + result.clear(); + } + } + + return result.empty() ? std::nullopt : std::make_optional(result); +} + +std::optional WICMetadataExtractor::ReadInteger(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + int64_t result = 0; + switch (propVar->Get().vt) + { + case VT_I1: result = propVar->Get().cVal; break; + case VT_I2: result = propVar->Get().iVal; break; + case VT_I4: result = propVar->Get().lVal; break; + case VT_I8: result = propVar->Get().hVal.QuadPart; break; + case VT_UI1: result = propVar->Get().bVal; break; + case VT_UI2: result = propVar->Get().uiVal; break; + case VT_UI4: result = propVar->Get().ulVal; break; + case VT_UI8: result = static_cast(propVar->Get().uhVal.QuadPart); break; + default: + return std::nullopt; + } + + return result; +} + +std::optional WICMetadataExtractor::ReadDouble(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + double result = 0.0; + switch (propVar->Get().vt) + { + case VT_R4: + result = static_cast(propVar->Get().fltVal); + break; + case VT_R8: + result = propVar->Get().dblVal; + break; + case VT_UI1 | VT_VECTOR: + case VT_UI4 | VT_VECTOR: + // Handle rational number (common for EXIF values) + // Rational data is stored as 8 bytes: 4-byte numerator + 4-byte denominator + if (propVar->Get().caub.cElems >= 8) + { + // ExposureBias (EXIF tag 37380) uses SRATIONAL type (signed rational) + // which can represent negative values like -0.33 EV for exposure compensation. + // Most other EXIF fields use RATIONAL type (unsigned) for values like aperture, shutter speed. + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse as signed rational: int32_t / int32_t + result = MetadataFormatHelper::ParseSingleSRational(propVar->Get().caub.pElems, 0); + break; + } + else + { + // Parse as unsigned rational: uint32_t / uint32_t + // First check if denominator is valid (non-zero) to avoid division by zero + const uint8_t* bytes = propVar->Get().caub.pElems; + uint32_t denominator = static_cast(bytes[4]) | + (static_cast(bytes[5]) << 8) | + (static_cast(bytes[6]) << 16) | + (static_cast(bytes[7]) << 24); + + if (denominator != 0) + { + result = MetadataFormatHelper::ParseSingleRational(propVar->Get().caub.pElems, 0); + break; + } + } + } + return std::nullopt; + default: + // Try integer conversion + switch (propVar->Get().vt) + { + case VT_I1: result = static_cast(propVar->Get().cVal); break; + case VT_I2: result = static_cast(propVar->Get().iVal); break; + case VT_I4: result = static_cast(propVar->Get().lVal); break; + case VT_I8: + { + // ExposureBias (EXIF tag 37380) may be stored as VT_I8 in some WIC implementations + // It represents a signed rational (SRATIONAL) packed into a 64-bit integer + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse signed rational from int64: low 32 bits = numerator, high 32 bits = denominator + // Some implementations may reverse the order, so we try both + int32_t numerator = static_cast(propVar->Get().hVal.QuadPart & 0xFFFFFFFF); + int32_t denominator = static_cast(propVar->Get().hVal.QuadPart >> 32); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + // Try reversed order: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast(propVar->Get().hVal.QuadPart >> 32); + denominator = static_cast(propVar->Get().hVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + result = 0.0; // Default to 0 if both attempts fail + } + } + } + else + { + // For other fields, treat VT_I8 as a simple 64-bit integer + result = static_cast(propVar->Get().hVal.QuadPart); + } + } + break; + case VT_UI1: result = static_cast(propVar->Get().bVal); break; + case VT_UI2: result = static_cast(propVar->Get().uiVal); break; + case VT_UI4: result = static_cast(propVar->Get().ulVal); break; + case VT_UI8: + { + // ExposureBias (EXIF tag 37380) may be stored as VT_UI8 in some WIC implementations + // Even though it's unsigned, we need to reinterpret it as signed for SRATIONAL + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse signed rational from uint64 (reinterpret as signed) + // Low 32 bits = numerator, high 32 bits = denominator + int32_t numerator = static_cast(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + int32_t denominator = static_cast(propVar->Get().uhVal.QuadPart >> 32); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + // Try reversed order: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast(propVar->Get().uhVal.QuadPart >> 32); + denominator = static_cast(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + result = 0.0; // Default to 0 if both attempts fail + } + } + } + else + { + // For other EXIF rational fields (unsigned), try both byte orders to handle different encodings + // First try: low 32 bits = numerator, high 32 bits = denominator + uint32_t numerator = static_cast(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + uint32_t denominator = static_cast(propVar->Get().uhVal.QuadPart >> 32); + + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + // Second try: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast(propVar->Get().uhVal.QuadPart >> 32); + denominator = static_cast(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + // Fall back to treating as regular integer if denominator is 0 + result = static_cast(propVar->Get().uhVal.QuadPart); + } + } + } + } + break; + default: + return std::nullopt; + } + } + + return result; +} + +std::optional WICMetadataExtractor::ReadMetadata(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + if (!reader) + return std::nullopt; + + PropVariantValue value; + + HRESULT hr = reader->GetMetadataByName(path.c_str(), value.GetAddressOf()); + if (SUCCEEDED(hr)) + { + return std::optional(std::move(value)); + } + + return std::nullopt; +} + +// GPS parsing functions have been moved to MetadataFormatHelper for better testability + +bool WICMetadataExtractor::ExtractXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata) +{ + return cache.GetOrLoadXMP(filePath, outMetadata, [this, &filePath](XMPMetadata& metadata) { + return LoadXMPMetadata(filePath, metadata); + }); +} + +bool WICMetadataExtractor::LoadXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata) +{ + if (!PathFileExistsW(filePath.c_str())) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction failed: File not found - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + auto decoder = CreateDecoder(filePath); + if (!decoder) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction: Unsupported format or unable to create decoder - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction failed: WIC decoder error - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr rootReader; + if (FAILED(frame->GetMetadataQueryReader(&rootReader))) + { + // No metadata is not necessarily an error - just means the file has no XMP data + return false; + } + + ExtractAllXMPFields(rootReader, outMetadata); + + return true; +} + +// Batch extraction method implementations +void WICMetadataExtractor::ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata) +{ + if (!reader) + return; + + // XMP Basic schema - xmp: namespace + metadata.creatorTool = ReadString(reader, XMP_CREATOR_TOOL); + metadata.createDate = ReadDateTime(reader, XMP_CREATE_DATE); + metadata.modifyDate = ReadDateTime(reader, XMP_MODIFY_DATE); + metadata.metadataDate = ReadDateTime(reader, XMP_METADATA_DATE); + + // Dublin Core schema - dc: namespace + metadata.title = ReadString(reader, XMP_DC_TITLE); + metadata.description = ReadString(reader, XMP_DC_DESCRIPTION); + metadata.creator = ReadString(reader, XMP_DC_CREATOR); + + // For dc:subject, we need to handle the array structure + // Try to read individual elements + // XMP allows for large arrays, but we limit to a reasonable number to avoid performance issues + constexpr int MAX_XMP_SUBJECTS = 50; + std::vector subjects; + for (int i = 0; i < MAX_XMP_SUBJECTS; ++i) + { + std::wstring subjectPath = L"/xmp/dc:subject/{ulong=" + std::to_wstring(i) + L"}"; + auto subject = ReadString(reader, subjectPath); + if (subject.has_value()) + { + subjects.push_back(subject.value()); + } + else + { + break; // No more subjects + } + } + if (!subjects.empty()) + { + metadata.subject = subjects; + } + + // XMP Rights Management schema + metadata.rights = ReadString(reader, XMP_RIGHTS); + + // XMP Media Management schema - xmpMM: namespace + metadata.documentID = ReadString(reader, XMP_MM_DOCUMENT_ID); + metadata.instanceID = ReadString(reader, XMP_MM_INSTANCE_ID); + metadata.originalDocumentID = ReadString(reader, XMP_MM_ORIGINAL_DOCUMENT_ID); + metadata.versionID = ReadString(reader, XMP_MM_VERSION_ID); +} + + + + + + + + + diff --git a/src/modules/powerrename/lib/WICMetadataExtractor.h b/src/modules/powerrename/lib/WICMetadataExtractor.h new file mode 100644 index 0000000000..868d18aa7c --- /dev/null +++ b/src/modules/powerrename/lib/WICMetadataExtractor.h @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include "MetadataTypes.h" +#include "MetadataResultCache.h" +#include "PropVariantValue.h" +#include +#include + +namespace PowerRenameLib +{ + /// + /// Windows Imaging Component (WIC) implementation for metadata extraction + /// Provides efficient batch extraction of all metadata types with built-in caching + /// + class WICMetadataExtractor + { + public: + WICMetadataExtractor(); + ~WICMetadataExtractor(); + + // Public metadata extraction methods + bool ExtractEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata); + + bool ExtractXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata); + + void ClearCache(); + + private: + // WIC factory management + static CComPtr GetWICFactory(); + static void InitializeWIC(); + + // WIC operations + CComPtr CreateDecoder(const std::wstring& filePath); + CComPtr GetMetadataReader(IWICBitmapDecoder* decoder); + + bool LoadEXIFMetadata(const std::wstring& filePath, EXIFMetadata& outMetadata); + bool LoadXMPMetadata(const std::wstring& filePath, XMPMetadata& outMetadata); + + // Batch extraction methods + void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata); + void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata); + void ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata); + + // Field reading helpers + std::optional ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional ReadString(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional ReadInteger(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional ReadDouble(IWICMetadataQueryReader* reader, const std::wstring& path); + + // Helper methods + std::optional ReadMetadata(IWICMetadataQueryReader* reader, const std::wstring& path); + + private: + MetadataResultCache cache; + }; +} diff --git a/src/modules/powerrename/lib/pch.h b/src/modules/powerrename/lib/pch.h index c5a4711a03..2ee372ae61 100644 --- a/src/modules/powerrename/lib/pch.h +++ b/src/modules/powerrename/lib/pch.h @@ -28,5 +28,17 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include + +// Windows Imaging Component (WIC) headers +#include +#include +#include +#include diff --git a/src/modules/powerrename/unittests/CommonRegExTests.h b/src/modules/powerrename/unittests/CommonRegExTests.h index b1b2b8d731..1b0ad30b92 100644 --- a/src/modules/powerrename/unittests/CommonRegExTests.h +++ b/src/modules/powerrename/unittests/CommonRegExTests.h @@ -611,6 +611,42 @@ TEST_METHOD (VerifyRandomizerRegExAllBackToBack) CoTaskMemFree(result); } +TEST_METHOD(VerifyCounterIncrementsWhenResultIsUnchanged) +{ + CComPtr renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + DWORD flags = EnumerateItems | UseRegularExpressions; + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + renameRegEx->PutSearchTerm(L"(.*)"); + renameRegEx->PutReplaceTerm(L"NewFile-${start=1}"); + + PWSTR result = nullptr; + unsigned long index = 0; + + renameRegEx->Replace(L"DocA", &result, index); + Assert::AreEqual(1ul, index, L"Counter should advance to 1 on first match."); + Assert::AreEqual(L"NewFile-1", result, L"First file should be renamed correctly."); + CoTaskMemFree(result); + + renameRegEx->Replace(L"DocB", &result, index); + Assert::AreEqual(2ul, index, L"Counter should advance to 2 on second match."); + Assert::AreEqual(L"NewFile-2", result, L"Second file should be renamed correctly."); + CoTaskMemFree(result); + + // The original term and the replacement are identical. + renameRegEx->Replace(L"NewFile-3", &result, index); + Assert::AreEqual(3ul, index, L"Counter must advance on a match, even if the new name is identical to the old one."); + Assert::AreEqual(L"NewFile-3", result, L"Filename should be unchanged on a coincidental match."); + CoTaskMemFree(result); + + // Test that there wasn't a "stall" in the numbering. + renameRegEx->Replace(L"DocC", &result, index); + Assert::AreEqual(4ul, index, L"Counter should continue sequentially after the coincidental match."); + Assert::AreEqual(L"NewFile-4", result, L"The subsequent file should receive the correct next number."); + CoTaskMemFree(result); +} + #ifndef TESTS_PARTIAL }; } diff --git a/src/modules/powerrename/unittests/HelpersTests.cpp b/src/modules/powerrename/unittests/HelpersTests.cpp new file mode 100644 index 0000000000..9a6d1b2028 --- /dev/null +++ b/src/modules/powerrename/unittests/HelpersTests.cpp @@ -0,0 +1,766 @@ +#include "pch.h" +#include "Helpers.h" +#include "MetadataPatternExtractor.h" +#include "MetadataTypes.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace HelpersTests +{ + TEST_CLASS(GetMetadataFileNameTests) + { + public: + TEST_METHOD(BasicPatternReplacement) + { + // Test basic pattern replacement with available metadata + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L"ISO 400"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_ISO 400", result); + } + + TEST_METHOD(PatternWithoutValueShowsPatternName) + { + // Test that patterns without values show the pattern name with $ prefix + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + // ISO is not in the map + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$ISO", result); + } + + TEST_METHOD(EmptyPatternShowsPatternName) + { + // Test that patterns with empty value show the pattern name with $ prefix + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L""; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$ISO", result); + } + + TEST_METHOD(EscapedDollarSigns) + { + // Test that $$ is converted to single $ + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$_ISO 400", result); + } + + TEST_METHOD(MultipleEscapedDollarSigns) + { + // Test that $$$$ is converted to $$ + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$$$price", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$$price", result); + } + + TEST_METHOD(OddDollarSignsWithPattern) + { + // Test that $$$ becomes $ followed by pattern + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$ISO 400", result); + } + + TEST_METHOD(LongestPatternMatchPriority) + { + // Test that longer patterns are matched first (DATE_TAKEN_YYYY vs DATE_TAKEN_YY) + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + patterns[L"DATE_TAKEN_YY"] = L"24"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$DATE_TAKEN_YYYY", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_2024", result); + } + + TEST_METHOD(MultiplePatterns) + { + // Test multiple patterns in one string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"CAMERA_MODEL"] = L"EOS R5"; + patterns[L"ISO"] = L"ISO 800"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$DATE_TAKEN_YYYY-$CAMERA_MAKE-$CAMERA_MODEL-$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024-Canon-EOS R5-ISO 800", result); + } + + TEST_METHOD(UnrecognizedPatternIgnored) + { + // Test that unrecognized patterns are not replaced + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$INVALID_PATTERN", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$INVALID_PATTERN", result); + } + + TEST_METHOD(NoPatterns) + { + // Test string with no patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_name_without_patterns", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_name_without_patterns", result); + } + + TEST_METHOD(EmptyInput) + { + // Test with empty input string + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"", patterns); + + Assert::IsTrue(FAILED(hr)); + } + + TEST_METHOD(NullInput) + { + // Test with null input + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, nullptr, patterns); + + Assert::IsTrue(FAILED(hr)); + } + + TEST_METHOD(DollarAtEnd) + { + // Test dollar sign at the end of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$ISO$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_ISO 400$", result); + } + + TEST_METHOD(ThreeDollarsAtEnd) + { + // Test three dollar signs at the end + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo$$$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo$$$", result); + } + + TEST_METHOD(ComplexMixedScenario) + { + // Test complex scenario with mixed patterns, escapes, and regular text + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L"ISO 400"; + patterns[L"APERTURE"] = L"f/2.8"; + patterns[L"LENS"] = L""; // Empty value + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$$price_$CAMERA_MAKE_$$$ISO_$APERTURE_$LENS_$$end", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$price_Canon_$ISO 400_f/2.8_$LENS_$end", result); + } + + TEST_METHOD(AllEXIFPatterns) + { + // Test with various EXIF patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"WIDTH"] = L"4000"; + patterns[L"HEIGHT"] = L"3000"; + patterns[L"FOCAL"] = L"50mm"; + patterns[L"SHUTTER"] = L"1/100s"; + patterns[L"FLASH"] = L"Flash Off"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"photo_$WIDTH x $HEIGHT_$FOCAL_$SHUTTER_$FLASH", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_4000 x 3000_50mm_1/100s_Flash Off", result); + } + + TEST_METHOD(AllXMPPatterns) + { + // Test with various XMP patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"Sunset"; + patterns[L"CREATOR"] = L"John Doe"; + patterns[L"DESCRIPTION"] = L"Beautiful sunset"; + patterns[L"CREATE_DATE_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$CREATE_DATE_YYYY-$TITLE-by-$CREATOR", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024-Sunset-by-John Doe", result); + } + + TEST_METHOD(DateComponentPatterns) + { + // Test date component patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + patterns[L"DATE_TAKEN_MM"] = L"03"; + patterns[L"DATE_TAKEN_DD"] = L"15"; + patterns[L"DATE_TAKEN_HH"] = L"14"; + patterns[L"DATE_TAKEN_mm"] = L"30"; + patterns[L"DATE_TAKEN_SS"] = L"45"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"photo_$DATE_TAKEN_YYYY-$DATE_TAKEN_MM-$DATE_TAKEN_DD_$DATE_TAKEN_HH-$DATE_TAKEN_mm-$DATE_TAKEN_SS", + patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_2024-03-15_14-30-45", result); + } + + TEST_METHOD(SpecialCharactersInValues) + { + // Test that special characters in metadata values are preserved + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"Photo (with) [brackets] & symbols!"; + patterns[L"DESCRIPTION"] = L"Test: value; with, punctuation."; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$TITLE - $DESCRIPTION", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Photo (with) [brackets] & symbols! - Test: value; with, punctuation.", result); + } + + TEST_METHOD(ConsecutivePatternsWithoutSeparator) + { + // Test consecutive patterns without separator + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"CAMERA_MODEL"] = L"R5"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE$CAMERA_MODEL", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"CanonR5", result); + } + + TEST_METHOD(PatternAtStart) + { + // Test pattern at the beginning of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE_photo", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Canon_photo", result); + } + + TEST_METHOD(PatternAtEnd) + { + // Test pattern at the end of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon", result); + } + + TEST_METHOD(OnlyPattern) + { + // Test string with only a pattern + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Canon", result); + } + }; + + TEST_CLASS(PatternMatchingTests) + { + public: + TEST_METHOD(VerifyLongestPatternMatching) + { + // This test verifies the greedy matching behavior + // When we have overlapping pattern names, the longest should be matched first + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_Y"] = L"4"; + patterns[L"DATE_TAKEN_YY"] = L"24"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + + // Should match YYYY (longest) + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$DATE_TAKEN_YYYY", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024", result); + + // Should match YY (available pattern) + hr = GetMetadataFileName(result, MAX_PATH, L"$DATE_TAKEN_YY", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"24", result); + } + + TEST_METHOD(PartialPatternNames) + { + // Test that partial pattern names don't match longer patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MODEL"] = L"EOS R5"; + + wchar_t result[MAX_PATH] = { 0 }; + // CAMERA is not a valid pattern, should not match + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MODEL", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"EOS R5", result); + } + + TEST_METHOD(CaseSensitivePatterns) + { + // Test that pattern names are case-sensitive + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + // lowercase should not match + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$camera_make", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$camera_make", result); // Not replaced + } + + TEST_METHOD(EmptyPatternMap) + { + // Test with empty pattern map + PowerRenameLib::MetadataPatternMap patterns; // Empty + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$ISO_$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + // Patterns should show with $ prefix since they're valid but have no values + Assert::AreEqual(L"photo_$ISO_$CAMERA_MAKE", result); + } + }; + + TEST_CLASS(EdgeCaseTests) + { + public: + TEST_METHOD(VeryLongString) + { + // Test with a very long input string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + std::wstring longInput = L"prefix_"; + for (int i = 0; i < 100; i++) + { + longInput += L"$CAMERA_MAKE_"; + } + + wchar_t result[4096] = { 0 }; + HRESULT hr = GetMetadataFileName(result, 4096, longInput.c_str(), patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + // Verify it starts correctly + Assert::IsTrue(wcsstr(result, L"prefix_Canon_") == result); + } + + TEST_METHOD(ManyConsecutiveDollars) + { + // Test with many consecutive dollar signs + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + // 8 dollars should become 4 dollars + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo$$$$$$$$name", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo$$$$name", result); + } + + TEST_METHOD(OnlyDollars) + { + // Test string with only dollar signs + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$$$$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$$", result); + } + + TEST_METHOD(UnicodeCharacters) + { + // Test with unicode characters in pattern values + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"照片_фото_φωτογραφία"; + patterns[L"CREATOR"] = L"张三_Иван_Γιάννης"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$TITLE-$CREATOR", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"照片_фото_φωτογραφία-张三_Иван_Γιάννης", result); + } + + TEST_METHOD(SingleDollar) + { + // Test with single dollar not followed by pattern + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"price$100", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"price$100", result); + } + + TEST_METHOD(DollarFollowedByNumber) + { + // Test dollar followed by numbers (not a pattern) + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"cost_$123.45", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"cost_$123.45", result); + } + }; + + TEST_CLASS(GetDatedFileNameTests) + { + public: + // Helper to get a fixed test time for consistent testing + SYSTEMTIME GetTestTime() + { + SYSTEMTIME testTime = { 0 }; + testTime.wYear = 2024; + testTime.wMonth = 3; // March + testTime.wDay = 15; // 15th + testTime.wHour = 14; // 2 PM (24-hour format) + testTime.wMinute = 30; + testTime.wSecond = 45; + testTime.wMilliseconds = 123; + testTime.wDayOfWeek = 5; // Friday (0=Sunday, 5=Friday) + return testTime; + } + + // Category 1: Tests for invalid patterns with extra characters (verify negative lookahead prevents wrong matching) + + TEST_METHOD(InvalidPattern_YYY_NotMatched) + { + // Test $YYY (3 Y's) is not a valid pattern and should remain unchanged + // Negative lookahead in $YY(?!Y) prevents matching $YYY + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYY", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_$YYY", result); // $YYY is invalid, should remain unchanged + } + + TEST_METHOD(InvalidPattern_DDD_NotPartiallyMatched) + { + // Test that $DDD (short weekday) is not confused with $DD (2-digit day) + // This verifies negative lookahead works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Fri", result); // Should be "Fri", not "15D" + } + + TEST_METHOD(InvalidPattern_MMM_NotPartiallyMatched) + { + // Test that $MMM (short month name) is not confused with $MM (2-digit month) + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar", result); // Should be "Mar", not "03M" + } + + TEST_METHOD(InvalidPattern_HHH_NotMatched) + { + // Test $HHH (3 H's) is not valid and negative lookahead prevents $HH from matching + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HHH", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_$HHH", result); // Should remain unchanged + } + + TEST_METHOD(SeparatedPatterns_SingleY) + { + // Test multiple $Y with separators works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$Y-$Y-$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_4-4-4", result); // Each $Y outputs "4" (from 2024) + } + + TEST_METHOD(SeparatedPatterns_SingleD) + { + // Test multiple $D with separators works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$D.$D.$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_15.15.15", result); // Each $D outputs "15" + } + + // Category 2: Tests for mixed length patterns (verify longer patterns don't get matched incorrectly) + + TEST_METHOD(MixedLengthYear_QuadFollowedBySingle) + { + // Test $YYYY$Y - should be 2024 + 4 + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYY$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_20244", result); + } + + TEST_METHOD(MixedLengthDay_TripleFollowedBySingle) + { + // Test $DDD$D - should be "Fri" + "15" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDD$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Fri15", result); + } + + TEST_METHOD(MixedLengthDay_QuadFollowedByDouble) + { + // Test $DDDD$DD - should be "Friday" + "15" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDDD$DD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Friday15", result); + } + + TEST_METHOD(MixedLengthMonth_TripleFollowedBySingle) + { + // Test $MMM$M - should be "Mar" + "3" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar3", result); + } + + // Category 3: Tests for boundary conditions (patterns at start, end, with special chars) + + TEST_METHOD(PatternAtStart) + { + // Test pattern at the very start of filename + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$YYYY$M$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024315", result); + } + + TEST_METHOD(PatternAtEnd) + { + // Test pattern at the very end of filename + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_4", result); + } + + TEST_METHOD(PatternWithSpecialChars) + { + // Test patterns surrounded by special characters + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file-$Y.$Y-$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file-4.4-3", result); + } + + TEST_METHOD(EmptyFileName) + { + // Test with empty input string - should return E_INVALIDARG + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"", testTime); + + Assert::IsTrue(FAILED(hr)); // Empty string should fail + Assert::AreEqual(E_INVALIDARG, hr); + } + + // Category 4: Tests to explicitly verify negative lookahead is working + + TEST_METHOD(NegativeLookahead_YearNotMatchedInYYYY) + { + // Verify $Y doesn't match when part of $YYYY + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYY", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_2024", result); // Should be "2024", not "202Y" + } + + TEST_METHOD(NegativeLookahead_MonthNotMatchedInMMM) + { + // Verify $M doesn't match when part of $MMM + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar", result); // Should be "Mar", not "3ar" + } + + TEST_METHOD(NegativeLookahead_DayNotMatchedInDDDD) + { + // Verify $D doesn't match when part of $DDDD + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Friday", result); // Should be "Friday", not "15riday" + } + + TEST_METHOD(NegativeLookahead_HourNotMatchedInHH) + { + // Verify $H doesn't match when part of $HH + // Note: $HH is 12-hour format, so 14:00 (2 PM) displays as "02" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HH", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_02", result); // 14:00 in 12-hour format is "02 PM" + } + + TEST_METHOD(NegativeLookahead_MillisecondNotMatchedInFFF) + { + // Verify $f doesn't match when part of $fff + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$fff", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_123", result); // Should be "123", not "1ff" + } + + // Category 5: Complex mixed scenarios + + TEST_METHOD(ComplexMixedPattern_AllFormats) + { + // Test a complex realistic filename with mixed pattern lengths + // Note: Using $hh for 24-hour format instead of $HH (which is 12-hour) + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"Photo_$YYYY-$MM-$DD_$hh-$mm-$ss_$fff", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Photo_2024-03-15_14-30-45_123", result); + } + + TEST_METHOD(ComplexMixedPattern_WithSeparators) + { + // Test multiple patterns of different lengths with separators + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$YYYY_$Y-$Y_$MM_$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024_4-4_03_3", result); + } + + TEST_METHOD(ComplexMixedPattern_DayFormats) + { + // Test all day format variations in one string + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$D-$DD-$DDD-$DDDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"15-15-Fri-Friday", result); + } + }; +} diff --git a/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp b/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp new file mode 100644 index 0000000000..6fd5badca8 --- /dev/null +++ b/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp @@ -0,0 +1,487 @@ +#include "pch.h" +#include "MetadataFormatHelper.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace PowerRenameLib; + +namespace MetadataFormatHelperTests +{ + TEST_CLASS(FormatApertureTests) + { + public: + TEST_METHOD(FormatAperture_ValidValue) + { + // Test formatting a typical aperture value + std::wstring result = MetadataFormatHelper::FormatAperture(2.8); + Assert::AreEqual(L"f/2.8", result.c_str()); + } + + TEST_METHOD(FormatAperture_SmallValue) + { + // Test small aperture (large f-number) + std::wstring result = MetadataFormatHelper::FormatAperture(1.4); + Assert::AreEqual(L"f/1.4", result.c_str()); + } + + TEST_METHOD(FormatAperture_LargeValue) + { + // Test large aperture (small f-number) + std::wstring result = MetadataFormatHelper::FormatAperture(22.0); + Assert::AreEqual(L"f/22.0", result.c_str()); + } + + TEST_METHOD(FormatAperture_RoundedValue) + { + // Test rounding to one decimal place + std::wstring result = MetadataFormatHelper::FormatAperture(5.66666); + Assert::AreEqual(L"f/5.7", result.c_str()); + } + + TEST_METHOD(FormatAperture_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatAperture(0.0); + Assert::AreEqual(L"f/0.0", result.c_str()); + } + }; + + TEST_CLASS(FormatShutterSpeedTests) + { + public: + TEST_METHOD(FormatShutterSpeed_FastSpeed) + { + // Test fast shutter speed (fraction of second) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.002); + Assert::AreEqual(L"1/500s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_VeryFastSpeed) + { + // Test very fast shutter speed + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.0001); + Assert::AreEqual(L"1/10000s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_SlowSpeed) + { + // Test slow shutter speed (more than 1 second) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(2.5); + Assert::AreEqual(L"2.5s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_OneSecond) + { + // Test exactly 1 second + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(1.0); + Assert::AreEqual(L"1.0s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_VerySlowSpeed) + { + // Test very slow shutter speed (< 1 second but close) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.5); + Assert::AreEqual(L"1/2s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.0); + Assert::AreEqual(L"0", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_Negative) + { + // Test negative value (invalid but should handle gracefully) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(-1.0); + Assert::AreEqual(L"0", result.c_str()); + } + }; + + TEST_CLASS(FormatISOTests) + { + public: + TEST_METHOD(FormatISO_TypicalValue) + { + // Test typical ISO value + std::wstring result = MetadataFormatHelper::FormatISO(400); + Assert::AreEqual(L"ISO 400", result.c_str()); + } + + TEST_METHOD(FormatISO_LowValue) + { + // Test low ISO value + std::wstring result = MetadataFormatHelper::FormatISO(100); + Assert::AreEqual(L"ISO 100", result.c_str()); + } + + TEST_METHOD(FormatISO_HighValue) + { + // Test high ISO value + std::wstring result = MetadataFormatHelper::FormatISO(12800); + Assert::AreEqual(L"ISO 12800", result.c_str()); + } + + TEST_METHOD(FormatISO_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatISO(0); + Assert::AreEqual(L"ISO", result.c_str()); + } + + TEST_METHOD(FormatISO_Negative) + { + // Test negative value (invalid but should handle gracefully) + std::wstring result = MetadataFormatHelper::FormatISO(-100); + Assert::AreEqual(L"ISO", result.c_str()); + } + }; + + TEST_CLASS(FormatFlashTests) + { + public: + TEST_METHOD(FormatFlash_Off) + { + // Test flash off (bit 0 = 0) + std::wstring result = MetadataFormatHelper::FormatFlash(0x0); + Assert::AreEqual(L"Flash Off", result.c_str()); + } + + TEST_METHOD(FormatFlash_On) + { + // Test flash on (bit 0 = 1) + std::wstring result = MetadataFormatHelper::FormatFlash(0x1); + Assert::AreEqual(L"Flash On", result.c_str()); + } + + TEST_METHOD(FormatFlash_OnWithAdditionalFlags) + { + // Test flash on with additional flags + std::wstring result = MetadataFormatHelper::FormatFlash(0x5); // 0b0101 = fired, return detected + Assert::AreEqual(L"Flash On", result.c_str()); + } + + TEST_METHOD(FormatFlash_OffWithAdditionalFlags) + { + // Test flash off with additional flags + std::wstring result = MetadataFormatHelper::FormatFlash(0x10); // Bit 0 is 0 + Assert::AreEqual(L"Flash Off", result.c_str()); + } + }; + + TEST_CLASS(FormatCoordinateTests) + { + public: + TEST_METHOD(FormatCoordinate_NorthLatitude) + { + // Test north latitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(40.7128, true); + Assert::AreEqual(L"40°42.77'N", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_SouthLatitude) + { + // Test south latitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(-33.8688, true); + Assert::AreEqual(L"33°52.13'S", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_EastLongitude) + { + // Test east longitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(151.2093, false); + Assert::AreEqual(L"151°12.56'E", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_WestLongitude) + { + // Test west longitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(-74.0060, false); + Assert::AreEqual(L"74°0.36'W", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_ZeroLatitude) + { + // Test equator (0 degrees latitude) + std::wstring result = MetadataFormatHelper::FormatCoordinate(0.0, true); + Assert::AreEqual(L"0°0.00'N", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_ZeroLongitude) + { + // Test prime meridian (0 degrees longitude) + std::wstring result = MetadataFormatHelper::FormatCoordinate(0.0, false); + Assert::AreEqual(L"0°0.00'E", result.c_str()); + } + }; + + TEST_CLASS(FormatSystemTimeTests) + { + public: + TEST_METHOD(FormatSystemTime_ValidDateTime) + { + // Test formatting a valid date and time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 3; + st.wDay = 15; + st.wHour = 14; + st.wMinute = 30; + st.wSecond = 45; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-03-15 14:30:45", result.c_str()); + } + + TEST_METHOD(FormatSystemTime_Midnight) + { + // Test midnight time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 1; + st.wDay = 1; + st.wHour = 0; + st.wMinute = 0; + st.wSecond = 0; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-01-01 00:00:00", result.c_str()); + } + + TEST_METHOD(FormatSystemTime_EndOfDay) + { + // Test end of day time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 12; + st.wDay = 31; + st.wHour = 23; + st.wMinute = 59; + st.wSecond = 59; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-12-31 23:59:59", result.c_str()); + } + }; + + TEST_CLASS(ParseSingleRationalTests) + { + public: + TEST_METHOD(ParseSingleRational_ValidValue) + { + // Test parsing a valid rational: 5/2 = 2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 2, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_IntegerResult) + { + // Test parsing rational that results in integer: 10/5 = 2.0 + uint8_t bytes[] = { 10, 0, 0, 0, 5, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(2.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_LargeNumerator) + { + // Test parsing with large numerator: 1000/100 = 10.0 + uint8_t bytes[] = { 0xE8, 0x03, 0, 0, 100, 0, 0, 0 }; // 1000 in little-endian + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(10.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_ZeroDenominator) + { + // Test parsing with zero denominator (should return 0.0) + uint8_t bytes[] = { 5, 0, 0, 0, 0, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_ZeroNumerator) + { + // Test parsing with zero numerator: 0/5 = 0.0 + uint8_t bytes[] = { 0, 0, 0, 0, 5, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_WithOffset) + { + // Test parsing with offset + uint8_t bytes[] = { 0xFF, 0xFF, 0xFF, 0xFF, 10, 0, 0, 0, 5, 0, 0, 0 }; // Offset = 4 + double result = MetadataFormatHelper::ParseSingleRational(bytes, 4); + Assert::AreEqual(2.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_NullPointer) + { + // Test with null pointer (should return 0.0) + double result = MetadataFormatHelper::ParseSingleRational(nullptr, 0); + Assert::AreEqual(0.0, result, 0.001); + } + }; + + TEST_CLASS(ParseSingleSRationalTests) + { + public: + TEST_METHOD(ParseSingleSRational_PositiveValue) + { + // Test parsing positive signed rational: 5/2 = 2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 2, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NegativeNumerator) + { + // Test parsing negative numerator: -5/2 = -2.5 + uint8_t bytes[] = { 0xFB, 0xFF, 0xFF, 0xFF, 2, 0, 0, 0 }; // -5 in two's complement + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NegativeDenominator) + { + // Test parsing negative denominator: 5/-2 = -2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 0xFE, 0xFF, 0xFF, 0xFF }; // -2 in two's complement + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_BothNegative) + { + // Test parsing both negative: -5/-2 = 2.5 + uint8_t bytes[] = { 0xFB, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_ExposureBias) + { + // Test typical exposure bias value: -1/3 ≈ -0.333 + uint8_t bytes[] = { 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0 }; // -1/3 + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-0.333, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_ZeroDenominator) + { + // Test with zero denominator (should return 0.0) + uint8_t bytes[] = { 5, 0, 0, 0, 0, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NullPointer) + { + // Test with null pointer (should return 0.0) + double result = MetadataFormatHelper::ParseSingleSRational(nullptr, 0); + Assert::AreEqual(0.0, result, 0.001); + } + }; + + TEST_CLASS(SanitizeForFileNameTests) + { + public: + TEST_METHOD(SanitizeForFileName_ValidString) + { + // Test string without illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Canon EOS 5D"); + Assert::AreEqual(L"Canon EOS 5D", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithColon) + { + // Test string with colon (illegal character) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photo:001"); + Assert::AreEqual(L"Photo_001", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithSlashes) + { + // Test string with forward and backward slashes + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photos/2024\\January"); + Assert::AreEqual(L"Photos_2024_January", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithMultipleIllegalChars) + { + // Test string with multiple illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L":File|Name*?.txt"); + Assert::AreEqual(L"_Test__File_Name__.txt", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithQuotes) + { + // Test string with quotes + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photo \"Best Shot\""); + Assert::AreEqual(L"Photo _Best Shot_", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithTrailingDot) + { + // Test string with trailing dot (should be removed) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename."); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithTrailingSpace) + { + // Test string with trailing space (should be removed) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename "); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithMultipleTrailingDotsAndSpaces) + { + // Test string with multiple trailing dots and spaces + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename. . "); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithControlCharacters) + { + // Test string with control characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"File\x01Name\x1F"); + Assert::AreEqual(L"File_Name_", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_EmptyString) + { + // Test empty string + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L""); + Assert::AreEqual(L"", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_OnlyIllegalCharacters) + { + // Test string with only illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"<>:\"/\\|?*"); + Assert::AreEqual(L"_________", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_OnlyTrailingCharacters) + { + // Test string with only dots and spaces (should return empty) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L". . "); + Assert::AreEqual(L"", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_UnicodeCharacters) + { + // Test string with valid Unicode characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"照片_2024年"); + Assert::AreEqual(L"照片_2024年", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_MixedContent) + { + // Test realistic metadata string with multiple issues + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Copyright © 2024: John/Jane Doe. "); + Assert::AreEqual(L"Copyright © 2024_ John_Jane Doe", result.c_str()); + } + }; +} diff --git a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp index a882802499..97c3f2fa2d 100644 --- a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp +++ b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp @@ -62,6 +62,12 @@ IFACEMETHODIMP CMockPowerRenameRegExEvents::OnFileTimeChanged(_In_ SYSTEMTIME fi return S_OK; } +IFACEMETHODIMP CMockPowerRenameRegExEvents::OnMetadataChanged() +{ + return S_OK; +} + + HRESULT CMockPowerRenameRegExEvents::s_CreateInstance(_Outptr_ IPowerRenameRegExEvents** ppsrree) { *ppsrree = nullptr; @@ -74,3 +80,4 @@ HRESULT CMockPowerRenameRegExEvents::s_CreateInstance(_Outptr_ IPowerRenameRegEx } return hr; } + diff --git a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h index f65108b123..b68f3775e8 100644 --- a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h +++ b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h @@ -19,6 +19,7 @@ public: IFACEMETHODIMP OnReplaceTermChanged(_In_ PCWSTR replaceTerm); IFACEMETHODIMP OnFlagsChanged(_In_ DWORD flags); IFACEMETHODIMP OnFileTimeChanged(_In_ SYSTEMTIME fileTime); + IFACEMETHODIMP OnMetadataChanged(); static HRESULT s_CreateInstance(_Outptr_ IPowerRenameRegExEvents** ppsrree); @@ -39,3 +40,4 @@ public: SYSTEMTIME m_fileTime = { 0 }; long m_refCount; }; + diff --git a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj index a6bd505342..3a3c5663aa 100644 --- a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj +++ b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj @@ -34,7 +34,7 @@ $(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories) - $(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;comctl32.lib;pathcch.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies) + $(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;comctl32.lib;pathcch.lib;windowscodecs.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies) @@ -49,11 +49,14 @@ + + + Create @@ -73,8 +76,30 @@ + + + true + + + true + + + true + + + true + + + true + + + + + + + diff --git a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters index db5bc09af4..42b6d1d17b 100644 --- a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters +++ b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters @@ -1,6 +1,7 @@  + @@ -30,6 +31,9 @@ {d34a343a-52ef-4296-83c9-a94fa62062ff} + + {8c9f3e2d-1a4b-4e5f-9c7d-2b8a6f3e1d4c} + @@ -38,5 +42,20 @@ + + testdata + + + testdata + + + testdata + + + testdata + + + testdata + \ No newline at end of file diff --git a/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp b/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp new file mode 100644 index 0000000000..c6d1d9b16c --- /dev/null +++ b/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp @@ -0,0 +1,244 @@ +#include "pch.h" +#include "WICMetadataExtractor.h" +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace PowerRenameLib; + +namespace WICMetadataExtractorTests +{ + // Helper function to get the test data directory path + std::wstring GetTestDataPath() + { + // Get the directory where the test DLL is located + // When running with vstest, we need to get the DLL module handle + HMODULE hModule = nullptr; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&GetTestDataPath), + &hModule); + + wchar_t modulePath[MAX_PATH]; + GetModuleFileNameW(hModule, modulePath, MAX_PATH); + std::filesystem::path dllPath(modulePath); + + // Navigate to the test data directory + // The test data is in the output directory alongside the DLL + std::filesystem::path testDataPath = dllPath.parent_path() / L"testdata"; + + return testDataPath.wstring(); + } + + TEST_CLASS(ExtractEXIFMetadataTests) + { + public: + TEST_METHOD(ExtractEXIF_InvalidFile_ReturnsFalse) + { + // Test that EXIF extraction fails for nonexistent file + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\nonexistent.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsFalse(result, L"EXIF extraction should fail for nonexistent file"); + } + + TEST_METHOD(ExtractEXIF_ExifTest_AllFields) + { + // Test exif_test.jpg which contains comprehensive EXIF data + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsTrue(result, L"EXIF extraction should succeed"); + + // Verify all the fields that are in exif_test.jpg + Assert::IsTrue(metadata.cameraMake.has_value(), L"Camera make should be present"); + Assert::AreEqual(L"samsung", metadata.cameraMake.value().c_str(), L"Camera make should be samsung"); + + Assert::IsTrue(metadata.cameraModel.has_value(), L"Camera model should be present"); + Assert::AreEqual(L"SM-G930P", metadata.cameraModel.value().c_str(), L"Camera model should be SM-G930P"); + + Assert::IsTrue(metadata.lensModel.has_value(), L"Lens model should be present"); + Assert::AreEqual(L"Samsung Galaxy S7 Rear Camera", metadata.lensModel.value().c_str(), L"Lens model should match"); + + Assert::IsTrue(metadata.iso.has_value(), L"ISO should be present"); + Assert::AreEqual(40, static_cast(metadata.iso.value()), L"ISO should be 40"); + + Assert::IsTrue(metadata.aperture.has_value(), L"Aperture should be present"); + Assert::AreEqual(1.7, metadata.aperture.value(), 0.01, L"Aperture should be f/1.7"); + + Assert::IsTrue(metadata.shutterSpeed.has_value(), L"Shutter speed should be present"); + Assert::AreEqual(0.000625, metadata.shutterSpeed.value(), 0.000001, L"Shutter speed should be 0.000625s"); + + Assert::IsTrue(metadata.focalLength.has_value(), L"Focal length should be present"); + Assert::AreEqual(4.2, metadata.focalLength.value(), 0.1, L"Focal length should be 4.2mm"); + + Assert::IsTrue(metadata.flash.has_value(), L"Flash should be present"); + Assert::AreEqual(0u, static_cast(metadata.flash.value()), L"Flash should be 0x0"); + + Assert::IsTrue(metadata.exposureBias.has_value(), L"Exposure bias should be present"); + Assert::AreEqual(0.0, metadata.exposureBias.value(), 0.01, L"Exposure bias should be 0 EV"); + + Assert::IsTrue(metadata.author.has_value(), L"Author should be present"); + Assert::AreEqual(L"Carl Seibert (Exif)", metadata.author.value().c_str(), L"Author should match"); + + Assert::IsTrue(metadata.copyright.has_value(), L"Copyright should be present"); + Assert::IsTrue(metadata.copyright.value().find(L"Carl Seibert") != std::wstring::npos, L"Copyright should contain Carl Seibert"); + } + + TEST_METHOD(ExtractEXIF_ExifTest2_WidthHeight) + { + // Test exif_test_2.jpg which only contains width and height + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test_2.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsTrue(result, L"EXIF extraction should succeed"); + + // exif_test_2.jpg only has width and height + Assert::IsTrue(metadata.width.has_value(), L"Width should be present"); + Assert::AreEqual(1080u, static_cast(metadata.width.value()), L"Width should be 1080px"); + + Assert::IsTrue(metadata.height.has_value(), L"Height should be present"); + Assert::AreEqual(810u, static_cast(metadata.height.value()), L"Height should be 810px"); + + // Other fields should not be present + Assert::IsFalse(metadata.cameraMake.has_value(), L"Camera make should not be present in exif_test_2.jpg"); + Assert::IsFalse(metadata.cameraModel.has_value(), L"Camera model should not be present in exif_test_2.jpg"); + Assert::IsFalse(metadata.iso.has_value(), L"ISO should not be present in exif_test_2.jpg"); + } + + TEST_METHOD(ExtractEXIF_ClearCache) + { + // Test cache clearing works + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test.jpg"; + + bool result1 = extractor.ExtractEXIFMetadata(testFile, metadata); + Assert::IsTrue(result1); + + extractor.ClearCache(); + + EXIFMetadata metadata2; + bool result2 = extractor.ExtractEXIFMetadata(testFile, metadata2); + Assert::IsTrue(result2); + + // Both calls should succeed + Assert::AreEqual(metadata.cameraMake.value().c_str(), metadata2.cameraMake.value().c_str()); + } + }; + + TEST_CLASS(ExtractXMPMetadataTests) + { + public: + TEST_METHOD(ExtractXMP_InvalidFile_ReturnsFalse) + { + // Test that XMP extraction fails for nonexistent file + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\nonexistent.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsFalse(result, L"XMP extraction should fail for nonexistent file"); + } + + TEST_METHOD(ExtractXMP_XmpTest_AllFields) + { + // Test xmp_test.jpg which contains comprehensive XMP data + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsTrue(result, L"XMP extraction should succeed"); + + // Verify all the fields that are in xmp_test.jpg + Assert::IsTrue(metadata.title.has_value(), L"Title should be present"); + Assert::AreEqual(L"object name here", metadata.title.value().c_str(), L"Title should match"); + + Assert::IsTrue(metadata.description.has_value(), L"Description should be present"); + Assert::IsTrue(metadata.description.value().find(L"This is a metadata test file") != std::wstring::npos, + L"Description should contain expected text"); + + Assert::IsTrue(metadata.rights.has_value(), L"Rights should be present"); + Assert::AreEqual(L"metadatamatters.blog", metadata.rights.value().c_str(), L"Rights should match"); + + Assert::IsTrue(metadata.creatorTool.has_value(), L"Creator tool should be present"); + Assert::IsTrue(metadata.creatorTool.value().find(L"Adobe Photoshop Lightroom") != std::wstring::npos, + L"Creator tool should contain Lightroom"); + + Assert::IsTrue(metadata.documentID.has_value(), L"Document ID should be present"); + Assert::IsTrue(metadata.documentID.value().find(L"xmp.did:") != std::wstring::npos, + L"Document ID should start with xmp.did:"); + + Assert::IsTrue(metadata.instanceID.has_value(), L"Instance ID should be present"); + Assert::IsTrue(metadata.instanceID.value().find(L"xmp.iid:") != std::wstring::npos, + L"Instance ID should start with xmp.iid:"); + + Assert::IsTrue(metadata.subject.has_value(), L"Subject keywords should be present"); + Assert::IsTrue(metadata.subject.value().size() > 0, L"Should have at least one keyword"); + } + + TEST_METHOD(ExtractXMP_XmpTest2_BasicFields) + { + // Test xmp_test_2.jpg which only contains basic XMP fields + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test_2.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsTrue(result, L"XMP extraction should succeed"); + + // xmp_test_2.jpg only has CreatorTool, DocumentID, and InstanceID + Assert::IsTrue(metadata.creatorTool.has_value(), L"Creator tool should be present"); + Assert::IsTrue(metadata.creatorTool.value().find(L"Adobe Photoshop CS6") != std::wstring::npos, + L"Creator tool should be Photoshop CS6"); + + Assert::IsTrue(metadata.documentID.has_value(), L"Document ID should be present"); + Assert::IsTrue(metadata.documentID.value().find(L"xmp.did:") != std::wstring::npos, + L"Document ID should start with xmp.did:"); + + Assert::IsTrue(metadata.instanceID.has_value(), L"Instance ID should be present"); + Assert::IsTrue(metadata.instanceID.value().find(L"xmp.iid:") != std::wstring::npos, + L"Instance ID should start with xmp.iid:"); + + // Other fields should not be present + Assert::IsFalse(metadata.title.has_value(), L"Title should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.description.has_value(), L"Description should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.rights.has_value(), L"Rights should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.creator.has_value(), L"Creator should not be present in xmp_test_2.jpg"); + } + + TEST_METHOD(ExtractXMP_ClearCache) + { + // Test cache clearing works + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg"; + + bool result1 = extractor.ExtractXMPMetadata(testFile, metadata); + Assert::IsTrue(result1); + + extractor.ClearCache(); + + XMPMetadata metadata2; + bool result2 = extractor.ExtractXMPMetadata(testFile, metadata2); + Assert::IsTrue(result2); + + // Both calls should succeed + Assert::AreEqual(metadata.title.value().c_str(), metadata2.title.value().c_str()); + } + }; +} diff --git a/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md b/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md new file mode 100644 index 0000000000..88844e57b8 --- /dev/null +++ b/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md @@ -0,0 +1,45 @@ +# Test Data Attribution + +This directory contains test image files used for PowerRename metadata extraction unit tests. These images are sourced from Wikimedia Commons and are used under the Creative Commons licenses specified below. + +## Test Files and Licenses + +### Files from Carlseibert + +**License:** [Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/) + +- `exif_test.jpg` - Uploaded by [Carlseibert](https://commons.wikimedia.org/wiki/File%3AMetadata_test_file_-_includes_data_in_IIM%2C_XMP%2C_and_Exif.jpg) on Wikimedia Commons +- `xmp_test.jpg` - Uploaded by [Carlseibert](https://commons.wikimedia.org/wiki/File%3AMetadata_test_file_-_includes_data_in_IIM%2C_XMP%2C_and_Exif.jpg) on Wikimedia Commons + +### Files from Edward Steven + +**License:** [Creative Commons Attribution-ShareAlike 2.0 Generic (CC BY-SA 2.0)](https://creativecommons.org/licenses/by-sa/2.0/) + +- `exif_test_2.jpg` - Uploaded by [Edward Steven](https://commons.wikimedia.org/wiki/File%3AAreca_hutchinsoniana.jpg) on Wikimedia Commons +- `xmp_test_2.jpg` - Uploaded by [Edward Steven](https://commons.wikimedia.org/wiki/File%3AAreca_hutchinsoniana.jpg) on Wikimedia Commons + +## Acknowledgments + +We gratefully acknowledge the contributions of Carlseibert and Edward Steven for making these images available under Creative Commons licenses. Their work enables us to test metadata extraction functionality with real-world EXIF and XMP data. + +## Usage + +These test images are used in PowerRename's unit tests to verify correct extraction of: +- EXIF metadata (camera make, model, ISO, aperture, shutter speed, etc.) +- XMP metadata (creator, title, description, copyright, etc.) +- GPS coordinates +- Date/time information + +## License Compliance + +These test images are distributed as part of the PowerToys source code repository under their original Creative Commons licenses: +- Files from Carlseibert: CC BY-SA 4.0 +- Files from Edward Steven: CC BY-SA 2.0 + +**Modifications:** These images have not been modified from their original versions downloaded from Wikimedia Commons. They are used in their original form for metadata extraction testing purposes. + +**Distribution:** These test images are included in the PowerToys source repository and comply with the terms of their respective Creative Commons licenses through proper attribution in this file. While included in the source code, these images are not distributed in end-user installation packages or releases. + +**Derivatives:** Any modifications or derivative works of these images must comply with the respective CC BY-SA license terms, including proper attribution and applying the same license to the modified versions. + +For more information about Creative Commons licenses, visit: https://creativecommons.org/licenses/ diff --git a/src/modules/powerrename/unittests/testdata/exif_test.jpg b/src/modules/powerrename/unittests/testdata/exif_test.jpg new file mode 100644 index 0000000000..5b40a5a688 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/exif_test.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/exif_test_2.jpg b/src/modules/powerrename/unittests/testdata/exif_test_2.jpg new file mode 100644 index 0000000000..ec2a3ad703 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/exif_test_2.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/xmp_test.jpg b/src/modules/powerrename/unittests/testdata/xmp_test.jpg new file mode 100644 index 0000000000..5b40a5a688 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/xmp_test.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg b/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg new file mode 100644 index 0000000000..ec2a3ad703 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg differ diff --git a/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml b/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml index 2de21bfa64..c9cb36d746 100644 --- a/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml +++ b/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml @@ -4,9 +4,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" - xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" - xmlns:ui="using:CommunityToolkit.WinUI" xmlns:winuiex="using:WinUIEx" MinWidth="480" MinHeight="320" @@ -20,7 +17,7 @@ - + #include +namespace +{ + json::JsonValue create_empty_shortcut_array_value() + { + return json::JsonValue::Parse(L"[]"); + } + + void ensure_ignored_conflict_properties_shape(json::JsonObject& obj) + { + if (!json::has(obj, L"ignored_shortcuts", json::JsonValueType::Array)) + { + obj.SetNamedValue(L"ignored_shortcuts", create_empty_shortcut_array_value()); + } + } + + json::JsonObject create_default_ignored_conflict_properties() + { + json::JsonObject obj; + ensure_ignored_conflict_properties_shape(obj); + return obj; + } + + DashboardSortOrder parse_dashboard_sort_order(const json::JsonObject& obj, DashboardSortOrder fallback) + { + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::Number)) + { + const auto raw_value = static_cast(obj.GetNamedNumber(L"dashboard_sort_order", static_cast(static_cast(fallback)))); + return raw_value == static_cast(DashboardSortOrder::ByStatus) ? DashboardSortOrder::ByStatus : DashboardSortOrder::Alphabetical; + } + + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::String)) + { + const auto raw = obj.GetNamedString(L"dashboard_sort_order"); + if (raw == L"ByStatus") + { + return DashboardSortOrder::ByStatus; + } + + if (raw == L"Alphabetical") + { + return DashboardSortOrder::Alphabetical; + } + } + + return fallback; + } +} + // TODO: would be nice to get rid of these globals, since they're basically cached json settings static std::wstring settings_theme = L"system"; static bool show_tray_icon = true; @@ -23,11 +71,16 @@ static bool download_updates_automatically = true; static bool show_whats_new_after_updates = true; static bool enable_experimentation = true; static bool enable_warnings_elevated_apps = true; +static DashboardSortOrder dashboard_sort_order = DashboardSortOrder::Alphabetical; +static json::JsonObject ignored_conflict_properties = create_default_ignored_conflict_properties(); json::JsonObject GeneralSettings::to_json() { json::JsonObject result; + auto ignoredProps = ignoredConflictProperties; + ensure_ignored_conflict_properties_shape(ignoredProps); + result.SetNamedValue(L"startup", json::value(isStartupEnabled)); if (!startupDisabledReason.empty()) { @@ -48,11 +101,13 @@ json::JsonObject GeneralSettings::to_json() result.SetNamedValue(L"download_updates_automatically", json::value(downloadUpdatesAutomatically)); result.SetNamedValue(L"show_whats_new_after_updates", json::value(showWhatsNewAfterUpdates)); result.SetNamedValue(L"enable_experimentation", json::value(enableExperimentation)); + result.SetNamedValue(L"dashboard_sort_order", json::value(static_cast(dashboardSortOrder))); result.SetNamedValue(L"is_admin", json::value(isAdmin)); result.SetNamedValue(L"enable_warnings_elevated_apps", json::value(enableWarningsElevatedApps)); result.SetNamedValue(L"theme", json::value(theme)); result.SetNamedValue(L"system_theme", json::value(systemTheme)); result.SetNamedValue(L"powertoys_version", json::value(powerToysVersion)); + result.SetNamedValue(L"ignored_conflict_properties", json::value(ignoredProps)); return result; } @@ -71,6 +126,18 @@ json::JsonObject load_general_settings() show_whats_new_after_updates = loaded.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = loaded.GetNamedBoolean(L"enable_experimentation", true); enable_warnings_elevated_apps = loaded.GetNamedBoolean(L"enable_warnings_elevated_apps", true); + dashboard_sort_order = parse_dashboard_sort_order(loaded, dashboard_sort_order); + + if (json::has(loaded, L"ignored_conflict_properties", json::JsonValueType::Object)) + { + ignored_conflict_properties = loaded.GetNamedObject(L"ignored_conflict_properties"); + } + else + { + ignored_conflict_properties = create_default_ignored_conflict_properties(); + } + + ensure_ignored_conflict_properties_shape(ignored_conflict_properties); return loaded; } @@ -89,11 +156,15 @@ GeneralSettings get_general_settings() .downloadUpdatesAutomatically = download_updates_automatically && is_user_admin, .showWhatsNewAfterUpdates = show_whats_new_after_updates, .enableExperimentation = enable_experimentation, + .dashboardSortOrder = dashboard_sort_order, .theme = settings_theme, .systemTheme = WindowsColors::is_dark_mode() ? L"dark" : L"light", - .powerToysVersion = get_product_version() + .powerToysVersion = get_product_version(), + .ignoredConflictProperties = ignored_conflict_properties }; + ensure_ignored_conflict_properties_shape(settings.ignoredConflictProperties); + settings.isStartupEnabled = is_auto_start_task_active_for_this_user(); for (auto& [name, powertoy] : modules()) @@ -117,6 +188,7 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) show_whats_new_after_updates = general_configs.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = general_configs.GetNamedBoolean(L"enable_experimentation", true); + dashboard_sort_order = parse_dashboard_sort_order(general_configs, dashboard_sort_order); // apply_general_settings is called by the runner's WinMain, so we can just force the run at startup gpo rule here. auto gpo_run_as_startup = powertoys_gpo::getConfiguredRunAtStartupValue(); @@ -232,6 +304,12 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) set_tray_icon_visible(show_tray_icon); } + if (json::has(general_configs, L"ignored_conflict_properties", json::JsonValueType::Object)) + { + ignored_conflict_properties = general_configs.GetNamedObject(L"ignored_conflict_properties"); + ensure_ignored_conflict_properties_shape(ignored_conflict_properties); + } + if (save) { GeneralSettings save_settings = get_general_settings(); diff --git a/src/runner/general_settings.h b/src/runner/general_settings.h index ef2224b132..b4f7638846 100644 --- a/src/runner/general_settings.h +++ b/src/runner/general_settings.h @@ -2,6 +2,12 @@ #include +enum class DashboardSortOrder +{ + Alphabetical = 0, + ByStatus = 1, +}; + struct GeneralSettings { bool isStartupEnabled; @@ -16,9 +22,11 @@ struct GeneralSettings bool downloadUpdatesAutomatically; bool showWhatsNewAfterUpdates; bool enableExperimentation; + DashboardSortOrder dashboardSortOrder; std::wstring theme; std::wstring systemTheme; std::wstring powerToysVersion; + json::JsonObject ignoredConflictProperties; json::JsonObject to_json(); }; diff --git a/src/runner/main.cpp b/src/runner/main.cpp index 527cf15bbb..c20293f9ed 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -161,6 +161,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.MouseJump.dll", L"PowerToys.AlwaysOnTopModuleInterface.dll", L"PowerToys.MousePointerCrosshairs.dll", + L"PowerToys.CursorWrap.dll", L"PowerToys.PowerAccentModuleInterface.dll", L"PowerToys.PowerOCRModuleInterface.dll", L"PowerToys.AdvancedPasteModuleInterface.dll", @@ -177,6 +178,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.WorkspacesModuleInterface.dll", L"PowerToys.CmdPalModuleInterface.dll", L"PowerToys.ZoomItModuleInterface.dll", + L"PowerToys.LightSwitchModuleInterface.dll", }; for (auto moduleSubdir : knownModules) @@ -190,10 +192,19 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow { std::wstring errorMessage = POWER_TOYS_MODULE_LOAD_FAIL; errorMessage += moduleSubdir; + +#ifdef _DEBUG + // In debug mode, simply log the warning and continue execution. + // This contrasts with the past approach where developers had to build all modules + // without errors before debugging—slowing down quick clone-and-fix iterations. + Logger::warn(L"Debug mode: {}", errorMessage); +#else + // In release mode, show error dialog as before MessageBoxW(NULL, errorMessage.c_str(), L"PowerToys", MB_OK | MB_ICONERROR); +#endif } } // Start initial powertoys @@ -326,6 +337,7 @@ int WINAPI WinMain(HINSTANCE /*hInstance*/, HINSTANCE /*hPrevInstance*/, LPSTR l GdiplusStartup(&gpToken, &gpStartupInput, NULL); winrt::init_apartment(); + const wchar_t* securityDescriptor = L"O:BA" // Owner: Builtin (local) administrator L"G:BA" // Group: Builtin (local) administrator @@ -517,5 +529,6 @@ int WINAPI WinMain(HINSTANCE /*hInstance*/, HINSTANCE /*hPrevInstance*/, LPSTR l } } stop_tray_icon(); + return result; } diff --git a/src/runner/packages.config b/src/runner/packages.config index ff4b059648..74d5ef5747 100644 --- a/src/runner/packages.config +++ b/src/runner/packages.config @@ -2,4 +2,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/runner/runner.instructions.md b/src/runner/runner.instructions.md new file mode 100644 index 0000000000..9dace8aae3 --- /dev/null +++ b/src/runner/runner.instructions.md @@ -0,0 +1,17 @@ +--- +applyTo: "**/*.cpp,**/*.c,**/*.h,**/*.hpp,**/*.rc" +--- +# Runner – tray / host process guidance + +Scope +- Module bootstrap, hotkey management, settings bridge, update/elevation handling. + +Guidelines +- If IPC/JSON contracts change, mirror updates in `src/settings-ui/**`. +- Keep module discovery in `src/runner/main.cpp` in sync when adding/removing modules. +- Keep startup lean: avoid blocking/network calls in early init path. +- Preserve GPO & elevation behaviors; confirm no regression in policy handling. +- Ask before modifying update workflow or elevation logic. + +Acceptance +- Stable startup, consistent contracts, no unnecessary logging noise. \ No newline at end of file diff --git a/src/runner/runner.vcxproj b/src/runner/runner.vcxproj index 90dafb5e45..1eae5a3573 100644 --- a/src/runner/runner.vcxproj +++ b/src/runner/runner.vcxproj @@ -1,8 +1,7 @@  - - + 81010002 @@ -15,10 +14,21 @@ + + + + + + + + Application v143 + None + true + true @@ -131,15 +141,38 @@ - - + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index b3ced3b858..473fa7ebe3 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -757,6 +757,8 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) return "ColorPicker"; case ESettingsWindowNames::CmdNotFound: return "CmdNotFound"; + case ESettingsWindowNames::LightSwitch: + return "LightSwitch"; case ESettingsWindowNames::FancyZones: return "FancyZones"; case ESettingsWindowNames::FileLocksmith: @@ -842,6 +844,10 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::CmdNotFound; } + else if (value == "LightSwitch") + { + return ESettingsWindowNames::LightSwitch; + } else if (value == "FancyZones") { return ESettingsWindowNames::FancyZones; diff --git a/src/runner/settings_window.h b/src/runner/settings_window.h index 611e24233e..e15108059f 100644 --- a/src/runner/settings_window.h +++ b/src/runner/settings_window.h @@ -10,6 +10,7 @@ enum class ESettingsWindowNames Awake, ColorPicker, CmdNotFound, + LightSwitch, FancyZones, FileLocksmith, Run, diff --git a/src/settings-ui/Settings.UI.Library/AIServiceType.cs b/src/settings-ui/Settings.UI.Library/AIServiceType.cs new file mode 100644 index 0000000000..27eccff1cf --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceType.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Supported AI service types for PowerToys AI experiences. + /// + public enum AIServiceType + { + Unknown = 0, + OpenAI, + AzureOpenAI, + Onnx, + ML, + FoundryLocal, + Mistral, + Google, + AzureAIInference, + Ollama, + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs new file mode 100644 index 0000000000..5b19212eba --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public static class AIServiceTypeExtensions + { + /// + /// Convert a persisted string value into an . + /// Supports historical casing and aliases. + /// + public static AIServiceType ToAIServiceType(this string serviceType) + { + if (string.IsNullOrWhiteSpace(serviceType)) + { + return AIServiceType.OpenAI; + } + + var normalized = serviceType.Trim().ToLowerInvariant(); + return normalized switch + { + "openai" => AIServiceType.OpenAI, + "azureopenai" or "azure" => AIServiceType.AzureOpenAI, + "onnx" => AIServiceType.Onnx, + "foundrylocal" or "foundry" or "fl" => AIServiceType.FoundryLocal, + "ml" or "windowsml" or "winml" => AIServiceType.ML, + "mistral" => AIServiceType.Mistral, + "google" or "googleai" or "googlegemini" => AIServiceType.Google, + "azureaiinference" or "azureinference" => AIServiceType.AzureAIInference, + "ollama" => AIServiceType.Ollama, + _ => AIServiceType.Unknown, + }; + } + + /// + /// Convert an to the canonical string used for persistence. + /// + public static string ToConfigurationString(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "OpenAI", + AIServiceType.AzureOpenAI => "AzureOpenAI", + AIServiceType.Onnx => "Onnx", + AIServiceType.FoundryLocal => "FoundryLocal", + AIServiceType.ML => "ML", + AIServiceType.Mistral => "Mistral", + AIServiceType.Google => "Google", + AIServiceType.AzureAIInference => "AzureAIInference", + AIServiceType.Ollama => "Ollama", + AIServiceType.Unknown => string.Empty, + _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, "Unsupported AI service type."), + }; + } + + /// + /// Convert an into the normalized key used internally. + /// + public static string ToNormalizedKey(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "openai", + AIServiceType.AzureOpenAI => "azureopenai", + AIServiceType.Onnx => "onnx", + AIServiceType.FoundryLocal => "foundrylocal", + AIServiceType.ML => "ml", + AIServiceType.Mistral => "mistral", + AIServiceType.Google => "google", + AIServiceType.AzureAIInference => "azureaiinference", + AIServiceType.Ollama => "ollama", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs new file mode 100644 index 0000000000..df01b1816a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Metadata information for an AI service type. + /// + public class AIServiceTypeMetadata + { + public AIServiceType ServiceType { get; init; } + + public string DisplayName { get; init; } + + public string IconPath { get; init; } + + public bool IsOnlineService { get; init; } + + public bool IsAvailableInUI { get; init; } = true; + + public bool IsLocalModel { get; init; } + + public string LegalDescription { get; init; } + + public string TermsLabel { get; init; } + + public Uri TermsUri { get; init; } + + public string PrivacyLabel { get; init; } + + public Uri PrivacyUri { get; init; } + + public bool HasLegalInfo => !string.IsNullOrWhiteSpace(LegalDescription); + + public bool HasTermsLink => TermsUri is not null && !string.IsNullOrEmpty(TermsLabel); + + public bool HasPrivacyLink => PrivacyUri is not null && !string.IsNullOrEmpty(PrivacyLabel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs new file mode 100644 index 0000000000..653b85553e --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library; + +/// +/// Centralized registry for AI service type metadata. +/// +public static class AIServiceTypeRegistry +{ + private static readonly Dictionary MetadataMap = new() + { + [AIServiceType.AzureAIInference] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureAIInference, + DisplayName = "Azure AI Inference", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Azure.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureAIInference_LegalDescription", + TermsLabel = "AdvancedPaste_AzureAIInference_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureAIInference_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.AzureOpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureOpenAI, + DisplayName = "Azure OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/AzureAI.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureOpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_AzureOpenAI_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureOpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.FoundryLocal] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.FoundryLocal, + DisplayName = "Foundry Local", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg", + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_FoundryLocal_LegalDescription", // Resource key for localized description + }, + [AIServiceType.Google] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Google, + DisplayName = "Google", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Gemini.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Google_LegalDescription", + TermsLabel = "AdvancedPaste_Google_TermsLabel", + TermsUri = new Uri("https://ai.google.dev/gemini-api/terms"), + PrivacyLabel = "AdvancedPaste_Google_PrivacyLabel", + PrivacyUri = new Uri("https://support.google.com/gemini/answer/13594961"), + }, + [AIServiceType.Mistral] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Mistral, + DisplayName = "Mistral", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Mistral.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Mistral_LegalDescription", + TermsLabel = "AdvancedPaste_Mistral_TermsLabel", + TermsUri = new Uri("https://mistral.ai/terms-of-service/"), + PrivacyLabel = "AdvancedPaste_Mistral_PrivacyLabel", + PrivacyUri = new Uri("https://mistral.ai/privacy-policy/"), + }, + [AIServiceType.ML] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.ML, + DisplayName = "Windows ML", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/WindowsML.svg", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IsAvailableInUI = false, + IsOnlineService = false, + IsLocalModel = true, + }, + [AIServiceType.Ollama] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Ollama, + DisplayName = "Ollama", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Ollama.svg", + + // Ollama provide online service, but we treat it as local model at first version since it can is known for local model. + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + TermsLabel = "AdvancedPaste_Ollama_TermsLabel", + TermsUri = new Uri("https://ollama.org/terms"), + PrivacyLabel = "AdvancedPaste_Ollama_PrivacyLabel", + PrivacyUri = new Uri("https://ollama.org/privacy"), + }, + [AIServiceType.Onnx] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Onnx, + DisplayName = "ONNX", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Onnx.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + [AIServiceType.OpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.OpenAI, + DisplayName = "OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_OpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_OpenAI_TermsLabel", + TermsUri = new Uri("https://openai.com/terms"), + PrivacyLabel = "AdvancedPaste_OpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://openai.com/privacy"), + }, + [AIServiceType.Unknown] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Unknown, + DisplayName = "Unknown", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + }; + + /// + /// Get metadata for a specific service type. + /// + public static AIServiceTypeMetadata GetMetadata(AIServiceType serviceType) + { + return MetadataMap.TryGetValue(serviceType, out var metadata) + ? metadata + : MetadataMap[AIServiceType.Unknown]; + } + + /// + /// Get metadata for a service type from its string representation. + /// + public static AIServiceTypeMetadata GetMetadata(string serviceType) + { + var type = serviceType.ToAIServiceType(); + return GetMetadata(type); + } + + /// + /// Get icon path for a service type. + /// + public static string GetIconPath(AIServiceType serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// + /// Get icon path for a service type from its string representation. + /// + public static string GetIconPath(string serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// + /// Get all service types available in the UI. + /// + public static IEnumerable GetAvailableServiceTypes() + { + return MetadataMap.Values.Where(m => m.IsAvailableInUI); + } + + /// + /// Get all online service types available in the UI. + /// + public static IEnumerable GetOnlineServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsOnlineService); + } + + /// + /// Get all local service types available in the UI. + /// + public static IEnumerable GetLocalServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsLocalModel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index 43baf89351..c981295906 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -14,6 +14,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { private int _id; private string _name = string.Empty; + private string _description = string.Empty; private string _prompt = string.Empty; private HotkeySettings _shortcut = new(); private bool _isShown; @@ -43,6 +44,13 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction } } + [JsonPropertyName("description")] + public string Description + { + get => _description; + set => Set(ref _description, value ?? string.Empty); + } + [JsonPropertyName("prompt")] public string Prompt { @@ -128,6 +136,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { Id = other.Id; Name = other.Name; + Description = other.Description; Prompt = other.Prompt; Shortcut = other.GetShortcutClone(); IsShown = other.IsShown; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs new file mode 100644 index 0000000000..b61f83dd3d --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Helper methods for migrating legacy Advanced Paste settings to the updated schema. + /// + public static class AdvancedPasteMigrationHelper + { + /// + /// Ensures an OpenAI provider exists in the configuration, creating one if necessary. + /// + /// The configuration instance. + /// The ensured provider and a flag indicating whether changes were made. + public static (PasteAIProviderDefinition Provider, bool Updated) EnsureOpenAIProvider(PasteAIConfiguration configuration) + { + if (configuration is null) + { + return (null, false); + } + + configuration.Providers ??= new ObservableCollection(); + + const string serviceTypeKey = "OpenAI"; + var existingProvider = configuration.Providers.FirstOrDefault(provider => string.Equals(provider.ServiceType, serviceTypeKey, StringComparison.OrdinalIgnoreCase)); + bool updated = false; + + if (existingProvider is null) + { + existingProvider = CreateProvider(serviceTypeKey); + configuration.Providers.Add(existingProvider); + updated = true; + } + + updated |= EnsureActiveProviderIsValid(configuration, existingProvider); + + return (existingProvider, updated); + } + + /// + /// Creates a provider with default values for the requested service type. + /// + private static PasteAIProviderDefinition CreateProvider(string serviceTypeKey) + { + var serviceType = serviceTypeKey.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + var provider = new PasteAIProviderDefinition + { + ServiceType = serviceTypeKey, + ModelName = PasteAIProviderDefaults.GetDefaultModelName(serviceType), + EndpointUrl = string.Empty, + ApiVersion = string.Empty, + DeploymentName = string.Empty, + ModelPath = string.Empty, + SystemPrompt = string.Empty, + ModerationEnabled = serviceType == AIServiceType.OpenAI, + IsLocalModel = metadata.IsLocalModel, + }; + + return provider; + } + + private static bool EnsureActiveProviderIsValid(PasteAIConfiguration configuration, PasteAIProviderDefinition preferredProvider = null) + { + if (configuration?.Providers is null || configuration.Providers.Count == 0) + { + if (!string.IsNullOrWhiteSpace(configuration?.ActiveProviderId)) + { + configuration.ActiveProviderId = string.Empty; + return true; + } + + return false; + } + + bool updated = false; + + var activeProvider = configuration.Providers.FirstOrDefault(provider => string.Equals(provider.Id, configuration.ActiveProviderId, StringComparison.OrdinalIgnoreCase)); + if (activeProvider is null) + { + activeProvider = preferredProvider ?? configuration.Providers.First(); + configuration.ActiveProviderId = activeProvider.Id; + updated = true; + } + + foreach (var provider in configuration.Providers) + { + bool shouldBeActive = string.Equals(provider.Id, configuration.ActiveProviderId, StringComparison.OrdinalIgnoreCase); + if (provider.IsActive != shouldBeActive) + { + provider.IsActive = shouldBeActive; + updated = true; + } + } + + return updated; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs index d40bd686d3..e3f6081266 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -2,6 +2,7 @@ // 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 System.Text.Json; using System.Text.Json.Serialization; @@ -23,13 +24,51 @@ namespace Microsoft.PowerToys.Settings.UI.Library PasteAsJsonShortcut = new(); CustomActions = new(); AdditionalActions = new(); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + EnableClipboardPreview = true; + PasteAIConfiguration = new(); } [JsonConverter(typeof(BoolPropertyJsonConverter))] - public bool IsAdvancedAIEnabled { get; set; } + public bool IsAIEnabled { get; set; } + + private bool? _legacyAdvancedAIEnabled; + + [JsonPropertyName("IsAdvancedAIEnabled")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public BoolProperty LegacyAdvancedAIEnabledProperty + { + get => null; + set + { + if (value is not null) + { + LegacyAdvancedAIEnabled = value.Value; + } + } + } + + [JsonIgnore] + public bool? LegacyAdvancedAIEnabled + { + get => _legacyAdvancedAIEnabled; + private set => _legacyAdvancedAIEnabled = value; + } + + public bool TryConsumeLegacyAdvancedAIEnabled(out bool value) + { + if (_legacyAdvancedAIEnabled is bool flag) + { + value = flag; + _legacyAdvancedAIEnabled = null; + return true; + } + + value = default; + return false; + } [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool ShowCustomPreview { get; set; } @@ -37,6 +76,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool CloseAfterLosingFocus { get; set; } + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool EnableClipboardPreview { get; set; } + [JsonPropertyName("advanced-paste-ui-hotkey")] public HotkeySettings AdvancedPasteUIShortcut { get; set; } @@ -57,6 +99,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library [CmdConfigureIgnoreAttribute] public AdvancedPasteAdditionalActions AdditionalActions { get; init; } + [JsonPropertyName("paste-ai-configuration")] + [CmdConfigureIgnoreAttribute] + public PasteAIConfiguration PasteAIConfiguration { get; set; } + public override string ToString() => JsonSerializer.Serialize(this); } diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs new file mode 100644 index 0000000000..cf66b4ba09 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +using Settings.UI.Library.Attributes; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapProperties + { + [CmdConfigureIgnore] + public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, false, true, false, 0x55); // Win + Alt + U + + [JsonPropertyName("activation_shortcut")] + public HotkeySettings ActivationShortcut { get; set; } + + [JsonPropertyName("auto_activate")] + public BoolProperty AutoActivate { get; set; } + + [JsonPropertyName("disable_wrap_during_drag")] + public BoolProperty DisableWrapDuringDrag { get; set; } + + public CursorWrapProperties() + { + ActivationShortcut = DefaultActivationShortcut; + AutoActivate = new BoolProperty(false); + DisableWrapDuringDrag = new BoolProperty(true); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs new file mode 100644 index 0000000000..8c9059123c --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs @@ -0,0 +1,53 @@ +// 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 System.Text.Json.Serialization; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig + { + public const string ModuleName = "CursorWrap"; + + [JsonPropertyName("properties")] + public CursorWrapProperties Properties { get; set; } + + public CursorWrapSettings() + { + Name = ModuleName; + Properties = new CursorWrapProperties(); + Version = "1.0"; + } + + public string GetModuleName() + { + return Name; + } + + public ModuleType GetModuleType() => ModuleType.CursorWrap; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_CursorWrap_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + // This can be utilized in the future if the settings.json file is to be modified/deleted. + public bool UpgradeSettingsConfiguration() + { + return false; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index 0e0556d442..d7100d9ae4 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -513,6 +513,39 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool cursorWrap; // defaulting to off + + [JsonPropertyName("CursorWrap")] + public bool CursorWrap + { + get => cursorWrap; + set + { + if (cursorWrap != value) + { + LogTelemetryEvent(value); + cursorWrap = value; + } + } + } + + private bool lightSwitch; + + [JsonPropertyName("LightSwitch")] + public bool LightSwitch + { + get => lightSwitch; + set + { + if (lightSwitch != value) + { + LogTelemetryEvent(value); + lightSwitch = value; + NotifyChange(); + } + } + } + private void NotifyChange() { notifyEnabledChangedAction?.Invoke(); diff --git a/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.cs b/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.cs new file mode 100644 index 0000000000..782bcccf48 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Settings.UI.Library.Enumerations +{ + public enum HostsDeleteBackupMode + { + Never = 0, + Count = 1, + Age = 2, + } +} diff --git a/src/settings-ui/Settings.UI.Library/FindMyMouseProperties.cs b/src/settings-ui/Settings.UI.Library/FindMyMouseProperties.cs index a028eb9e43..b0d1b347ab 100644 --- a/src/settings-ui/Settings.UI.Library/FindMyMouseProperties.cs +++ b/src/settings-ui/Settings.UI.Library/FindMyMouseProperties.cs @@ -31,9 +31,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("spotlight_color")] public StringProperty SpotlightColor { get; set; } - [JsonPropertyName("overlay_opacity")] - public IntProperty OverlayOpacity { get; set; } - [JsonPropertyName("spotlight_radius")] public IntProperty SpotlightRadius { get; set; } @@ -61,9 +58,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library IncludeWinKey = new BoolProperty(false); ActivationShortcut = DefaultActivationShortcut; DoNotActivateOnGameMode = new BoolProperty(true); - BackgroundColor = new StringProperty("#000000"); - SpotlightColor = new StringProperty("#FFFFFF"); - OverlayOpacity = new IntProperty(50); + BackgroundColor = new StringProperty("#80000000"); // ARGB (#AARRGGBB) + SpotlightColor = new StringProperty("#80FFFFFF"); SpotlightRadius = new IntProperty(100); AnimationDurationMs = new IntProperty(500); SpotlightInitialZoom = new IntProperty(9); diff --git a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs index 3d295284e3..0f380aca78 100644 --- a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs +++ b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs @@ -13,6 +13,12 @@ using Settings.UI.Library.Attributes; namespace Microsoft.PowerToys.Settings.UI.Library { + public enum DashboardSortOrder + { + Alphabetical, + ByStatus, + } + public class GeneralSettings : ISettingsConfig { // Gets or sets a value indicating whether run powertoys on start-up. @@ -76,6 +82,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("enable_experimentation")] public bool EnableExperimentation { get; set; } + [JsonPropertyName("dashboard_sort_order")] + public DashboardSortOrder DashboardSortOrder { get; set; } + + [JsonPropertyName("ignored_conflict_properties")] + public ShortcutConflictProperties IgnoredConflictProperties { get; set; } + public GeneralSettings() { Startup = false; @@ -86,6 +98,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library ShowNewUpdatesToastNotification = true; AutoDownloadUpdates = false; EnableExperimentation = true; + DashboardSortOrder = DashboardSortOrder.Alphabetical; Theme = "system"; SystemTheme = "light"; try @@ -100,6 +113,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Enabled = new EnabledModules(); CustomActionName = string.Empty; + IgnoredConflictProperties = new ShortcutConflictProperties(); } // converts the current to a json string. @@ -137,6 +151,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library // If there is an issue with the version number format, don't migrate settings. } + // Ensure IgnoredConflictProperties is initialized (for backward compatibility) + if (IgnoredConflictProperties == null) + { + IgnoredConflictProperties = new ShortcutConflictProperties(); + return true; // Indicate that settings were upgraded + } + return false; } diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.cs b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.cs new file mode 100644 index 0000000000..8e357534af --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Settings.UI.Library.Helpers +{ + public class SearchLocation + { + public string City { get; set; } + + public string Country { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public SearchLocation(string city, string country, double latitude, double longitude) + { + City = city; + Country = country; + Latitude = latitude; + Longitude = longitude; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs new file mode 100644 index 0000000000..24f0846a02 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + public static class SearchLocationLoader + { + private static readonly List LocationDataList = new List(); + + public static IEnumerable GetAll() + { + return LocationDataList + .GroupBy(l => $"{l.Country}|{l.City}|{l.Latitude.ToString(CultureInfo.InvariantCulture)}|{l.Longitude.ToString(CultureInfo.InvariantCulture)}") + .Select(g => g.First()) + .OrderBy(l => l.Country, StringComparer.OrdinalIgnoreCase) + .ThenBy(l => l.City, StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs b/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs new file mode 100644 index 0000000000..6b69fee755 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public static class SunCalc + { + public static SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day) + { + double zenith = 90.833; // official sunrise/sunset + + int n1 = (int)Math.Floor(275.0 * month / 9.0); + int n2 = (int)Math.Floor((month + 9.0) / 12.0); + int n3 = (int)Math.Floor(1.0 + Math.Floor((year - (4.0 * Math.Floor(year / 4.0)) + 2.0) / 3.0)); + int n = n1 - (n2 * n3) + day - 30; + + double? riseUT = CalcTime(isSunrise: true); + double? setUT = CalcTime(isSunrise: false); + + var riseLocal = ToLocal(riseUT, year, month, day); + var setLocal = ToLocal(setUT, year, month, day); + + var result = new SunTimes + { + HasSunrise = riseLocal.HasValue, + HasSunset = setLocal.HasValue, + SunriseHour = riseLocal?.Hour ?? -1, + SunriseMinute = riseLocal?.Minute ?? -1, + SunsetHour = setLocal?.Hour ?? -1, + SunsetMinute = setLocal?.Minute ?? -1, + }; + + return result; + + // Local functions + double? CalcTime(bool isSunrise) + { + double lngHour = longitude / 15.0; + double t = isSunrise ? n + ((6 - lngHour) / 24.0) : n + ((18 - lngHour) / 24.0); + + double m1 = (0.9856 * t) - 3.289; + double l = m1 + (1.916 * Math.Sin(Deg2Rad(m1))) + (0.020 * Math.Sin(2 * Deg2Rad(m1))) + 282.634; + l = NormalizeDegrees(l); + + double rA = Rad2Deg(Math.Atan(0.91764 * Math.Tan(Deg2Rad(l)))); + rA = NormalizeDegrees(rA); + + double lquadrant = Math.Floor(l / 90.0) * 90.0; + double rAquadrant = Math.Floor(rA / 90.0) * 90.0; + rA = rA + (lquadrant - rAquadrant); + rA /= 15.0; + + double sinDec = 0.39782 * Math.Sin(Deg2Rad(l)); + double cosDec = Math.Cos(Math.Asin(sinDec)); + + double cosH = (Math.Cos(Deg2Rad(zenith)) - (sinDec * Math.Sin(Deg2Rad(latitude)))) + / (cosDec * Math.Cos(Deg2Rad(latitude))); + + if (cosH > 1.0 || cosH < -1.0) + { + // Sun never rises or never sets on this date at this location + return null; + } + + double h = isSunrise ? 360.0 - Rad2Deg(Math.Acos(cosH)) : Rad2Deg(Math.Acos(cosH)); + h /= 15.0; + + double t1 = h + rA - (0.06571 * t) - 6.622; + double uT = t1 - lngHour; + uT = NormalizeHours(uT); + + return uT; + } + + static (int Hour, int Minute)? ToLocal(double? ut, int y, int m, int d) + { + if (!ut.HasValue) + { + return null; + } + + // Convert fractional hours to hh:mm with proper rounding + int hours = (int)Math.Floor(ut.Value); + int minutes = (int)((ut.Value - hours) * 60.0); + + // Normalize minute overflow + if (minutes == 60) + { + minutes = 0; + hours = (hours + 1) % 24; + } + + // Build a UTC DateTime on the given date + var utc = new DateTime(y, m, d, hours, minutes, 0, DateTimeKind.Utc); + + // Convert to local time using system time zone rules for that date + var local = TimeZoneInfo.ConvertTimeFromUtc(utc, TimeZoneInfo.Local); + + return (local.Hour, local.Minute); + } + + static double Deg2Rad(double deg) => deg * Math.PI / 180.0; + static double Rad2Deg(double rad) => rad * 180.0 / Math.PI; + + static double NormalizeDegrees(double angle) + { + angle %= 360.0; + if (angle < 0) + { + angle += 360.0; + } + + return angle; + } + + static double NormalizeHours(double hours) + { + hours %= 24.0; + if (hours < 0) + { + hours += 24.0; + } + + return hours; + } + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.cs b/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.cs new file mode 100644 index 0000000000..2f4f31fc57 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public struct SunTimes + { + public int SunriseHour; + public int SunriseMinute; + public int SunsetHour; + public int SunsetMinute; + public string Text; + + public bool HasSunrise; + public bool HasSunset; + } +} diff --git a/src/settings-ui/Settings.UI.Library/HostsProperties.cs b/src/settings-ui/Settings.UI.Library/HostsProperties.cs index 6ec9924049..b4feedce45 100644 --- a/src/settings-ui/Settings.UI.Library/HostsProperties.cs +++ b/src/settings-ui/Settings.UI.Library/HostsProperties.cs @@ -1,10 +1,10 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; +using System.IO; using System.Text.Json.Serialization; - -using Settings.UI.Library.Attributes; using Settings.UI.Library.Enumerations; namespace Microsoft.PowerToys.Settings.UI.Library @@ -27,6 +27,17 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool NoLeadingSpaces { get; set; } + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool BackupHosts { get; set; } + + public string BackupPath { get; set; } + + public HostsDeleteBackupMode DeleteBackupsMode { get; set; } + + public int DeleteBackupsDays { get; set; } + + public int DeleteBackupsCount { get; set; } + public HostsProperties() { ShowStartupWarning = true; @@ -35,6 +46,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library AdditionalLinesPosition = HostsAdditionalLinesPosition.Top; Encoding = HostsEncoding.Utf8; NoLeadingSpaces = false; + BackupHosts = true; + BackupPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"System32\drivers\etc"); + DeleteBackupsMode = HostsDeleteBackupMode.Age; + DeleteBackupsDays = 15; + DeleteBackupsCount = 5; } } } diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs index a420ec7a2b..0c76ddf8ea 100644 --- a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs @@ -2,11 +2,7 @@ // 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.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts { @@ -16,6 +12,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts public bool IsSystemConflict { get; set; } + public bool ConflictIgnored { get; set; } + + public bool ConflictVisible => !ConflictIgnored; + + public bool ShouldShowSysConflict => !ConflictIgnored && IsSystemConflict; + public List Modules { get; set; } } } diff --git a/src/settings-ui/Settings.UI.Library/HotkeySettings.cs b/src/settings-ui/Settings.UI.Library/HotkeySettings.cs index 724e1b5159..b5fa41fcf6 100644 --- a/src/settings-ui/Settings.UI.Library/HotkeySettings.cs +++ b/src/settings-ui/Settings.UI.Library/HotkeySettings.cs @@ -20,6 +20,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library private bool _hasConflict; private string _conflictDescription; private bool _isSystemConflict; + private bool _ignoreConflict; public event PropertyChangedEventHandler PropertyChanged; @@ -57,6 +58,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library HasConflict = false; } + [JsonIgnore] + public bool IgnoreConflict + { + get => _ignoreConflict; + set + { + if (_ignoreConflict != value) + { + _ignoreConflict = value; + OnPropertyChanged(); + } + } + } + + [JsonIgnore] public bool HasConflict { get => _hasConflict; @@ -70,9 +86,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + [JsonIgnore] public string ConflictDescription { - get => _conflictDescription ?? string.Empty; + get => _ignoreConflict ? null : _conflictDescription; set { if (_conflictDescription != value) @@ -83,6 +100,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + [JsonIgnore] public bool IsSystemConflict { get => _isSystemConflict; diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs new file mode 100644 index 0000000000..8f5bf88a19 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class LightSwitchProperties + { + public const bool DefaultChangeSystem = true; + public const bool DefaultChangeApps = true; + public const int DefaultLightTime = 480; + public const int DefaultDarkTime = 1200; + public const int DefaultSunriseOffset = 0; + public const int DefaultSunsetOffset = 0; + public const string DefaultLatitude = "0.0"; + public const string DefaultLongitude = "0.0"; + public const string DefaultScheduleMode = "Off"; + public static readonly HotkeySettings DefaultToggleThemeHotkey = new HotkeySettings(true, true, false, true, 0x44); // Ctrl+Win+Shift+D + + public LightSwitchProperties() + { + ChangeSystem = new BoolProperty(DefaultChangeSystem); + ChangeApps = new BoolProperty(DefaultChangeApps); + LightTime = new IntProperty(DefaultLightTime); + DarkTime = new IntProperty(DefaultDarkTime); + Latitude = new StringProperty(DefaultLatitude); + Longitude = new StringProperty(DefaultLongitude); + SunriseOffset = new IntProperty(DefaultSunriseOffset); + SunsetOffset = new IntProperty(DefaultSunsetOffset); + ScheduleMode = new StringProperty(DefaultScheduleMode); + ToggleThemeHotkey = new KeyboardKeysProperty(DefaultToggleThemeHotkey); + } + + [JsonPropertyName("changeSystem")] + public BoolProperty ChangeSystem { get; set; } + + [JsonPropertyName("changeApps")] + public BoolProperty ChangeApps { get; set; } + + [JsonPropertyName("lightTime")] + public IntProperty LightTime { get; set; } + + [JsonPropertyName("darkTime")] + public IntProperty DarkTime { get; set; } + + [JsonPropertyName("sunrise_offset")] + public IntProperty SunriseOffset { get; set; } + + [JsonPropertyName("sunset_offset")] + public IntProperty SunsetOffset { get; set; } + + [JsonPropertyName("latitude")] + public StringProperty Latitude { get; set; } + + [JsonPropertyName("longitude")] + public StringProperty Longitude { get; set; } + + [JsonPropertyName("scheduleMode")] + public StringProperty ScheduleMode { get; set; } + + [JsonPropertyName("toggle-theme-hotkey")] + public KeyboardKeysProperty ToggleThemeHotkey { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs new file mode 100644 index 0000000000..b4eae2d1ba --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json.Serialization; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Settings.UI.Library +{ + public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable, IHotkeyConfig + { + public const string ModuleName = "LightSwitch"; + + public LightSwitchSettings() + { + Name = ModuleName; + Version = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + Properties = new LightSwitchProperties(); + } + + [JsonPropertyName("properties")] + public LightSwitchProperties Properties { get; set; } + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ToggleThemeHotkey.Value, + value => Properties.ToggleThemeHotkey.Value = value ?? LightSwitchProperties.DefaultToggleThemeHotkey, + "LightSwitch_ThemeToggle_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + public ModuleType GetModuleType() => ModuleType.LightSwitch; + + public object Clone() + { + return new LightSwitchSettings() + { + Name = Name, + Version = Version, + Properties = new LightSwitchProperties() + { + ChangeSystem = new BoolProperty(Properties.ChangeSystem.Value), + ChangeApps = new BoolProperty(Properties.ChangeApps.Value), + ScheduleMode = new StringProperty(Properties.ScheduleMode.Value), + LightTime = new IntProperty((int)Properties.LightTime.Value), + DarkTime = new IntProperty((int)Properties.DarkTime.Value), + SunriseOffset = new IntProperty((int)Properties.SunriseOffset.Value), + SunsetOffset = new IntProperty((int)Properties.SunsetOffset.Value), + Latitude = new StringProperty(Properties.Latitude.Value), + Longitude = new StringProperty(Properties.Longitude.Value), + ToggleThemeHotkey = new KeyboardKeysProperty(Properties.ToggleThemeHotkey.Value), + }, + }; + } + + public string GetModuleName() + { + return Name; + } + + public bool UpgradeSettingsConfiguration() + { + return false; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs index 54542194c0..83427a9f30 100644 --- a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs +++ b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs @@ -40,6 +40,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("crosshairs_border_size")] public IntProperty CrosshairsBorderSize { get; set; } + [JsonPropertyName("crosshairs_orientation")] + public IntProperty CrosshairsOrientation { get; set; } + [JsonPropertyName("crosshairs_auto_hide")] public BoolProperty CrosshairsAutoHide { get; set; } @@ -68,6 +71,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library CrosshairsThickness = new IntProperty(5); CrosshairsBorderColor = new StringProperty("#FFFFFF"); CrosshairsBorderSize = new IntProperty(1); + CrosshairsOrientation = new IntProperty(0); // Default to both (0=Both, 1=Vertical, 2=Horizontal) CrosshairsAutoHide = new BoolProperty(false); CrosshairsIsFixedLengthEnabled = new BoolProperty(false); CrosshairsFixedLength = new IntProperty(1); diff --git a/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs new file mode 100644 index 0000000000..3308ab535e --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Configuration for Paste AI features (custom action transformations like custom prompt processing) + /// + public class PasteAIConfiguration : INotifyPropertyChanged + { + private string _activeProviderId = string.Empty; + private ObservableCollection _providers = new(); + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("active-provider-id")] + public string ActiveProviderId + { + get => _activeProviderId; + set => SetProperty(ref _activeProviderId, value ?? string.Empty); + } + + [JsonPropertyName("providers")] + public ObservableCollection Providers + { + get => _providers; + set => SetProperty(ref _providers, value ?? new ObservableCollection()); + } + + [JsonIgnore] + public PasteAIProviderDefinition ActiveProvider + { + get + { + if (_providers is null || _providers.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(_activeProviderId)) + { + var match = _providers.FirstOrDefault(provider => string.Equals(provider.Id, _activeProviderId, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + return match; + } + } + + return _providers[0]; + } + } + + [JsonIgnore] + public AIServiceType ActiveServiceTypeKind => ActiveProvider?.ServiceTypeKind ?? AIServiceType.OpenAI; + + public override string ToString() + => JsonSerializer.Serialize(this); + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs new file mode 100644 index 0000000000..1ccfa753fa --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Provides default values for Paste AI provider definitions. + /// + public static class PasteAIProviderDefaults + { + /// + /// Gets the default model name for a given AI service type. + /// + public static string GetDefaultModelName(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "gpt-4o", + AIServiceType.AzureOpenAI => "gpt-4o", + AIServiceType.Mistral => "mistral-large-latest", + AIServiceType.Google => "gemini-1.5-pro", + AIServiceType.AzureAIInference => "gpt-4o-mini", + AIServiceType.Ollama => "llama3", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs new file mode 100644 index 0000000000..0fbb3328e7 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Represents a single Paste AI provider configuration entry. + /// + public class PasteAIProviderDefinition : INotifyPropertyChanged + { + private string _id = Guid.NewGuid().ToString("N"); + private string _serviceType = "OpenAI"; + private string _modelName = string.Empty; + private string _endpointUrl = string.Empty; + private string _apiVersion = string.Empty; + private string _deploymentName = string.Empty; + private string _modelPath = string.Empty; + private string _systemPrompt = string.Empty; + private bool _moderationEnabled = true; + private bool _isActive; + private bool _enableAdvancedAI; + private bool _isLocalModel; + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("id")] + public string Id + { + get => _id; + set => SetProperty(ref _id, value); + } + + [JsonPropertyName("service-type")] + public string ServiceType + { + get => _serviceType; + set + { + if (SetProperty(ref _serviceType, string.IsNullOrWhiteSpace(value) ? "OpenAI" : value)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonIgnore] + public AIServiceType ServiceTypeKind + { + get => ServiceType.ToAIServiceType(); + set => ServiceType = value.ToConfigurationString(); + } + + [JsonPropertyName("model-name")] + public string ModelName + { + get => _modelName; + set + { + if (SetProperty(ref _modelName, value ?? string.Empty)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonPropertyName("endpoint-url")] + public string EndpointUrl + { + get => _endpointUrl; + set => SetProperty(ref _endpointUrl, value ?? string.Empty); + } + + [JsonPropertyName("api-version")] + public string ApiVersion + { + get => _apiVersion; + set => SetProperty(ref _apiVersion, value ?? string.Empty); + } + + [JsonPropertyName("deployment-name")] + public string DeploymentName + { + get => _deploymentName; + set => SetProperty(ref _deploymentName, value ?? string.Empty); + } + + [JsonPropertyName("model-path")] + public string ModelPath + { + get => _modelPath; + set => SetProperty(ref _modelPath, value ?? string.Empty); + } + + [JsonPropertyName("system-prompt")] + public string SystemPrompt + { + get => _systemPrompt; + set => SetProperty(ref _systemPrompt, value?.Trim() ?? string.Empty); + } + + [JsonPropertyName("moderation-enabled")] + public bool ModerationEnabled + { + get => _moderationEnabled; + set => SetProperty(ref _moderationEnabled, value); + } + + [JsonPropertyName("enable-advanced-ai")] + public bool EnableAdvancedAI + { + get => _enableAdvancedAI; + set => SetProperty(ref _enableAdvancedAI, value); + } + + [JsonPropertyName("is-local-model")] + public bool IsLocalModel + { + get => _isLocalModel; + set => SetProperty(ref _isLocalModel, value); + } + + [JsonIgnore] + public bool IsActive + { + get => _isActive; + set => SetProperty(ref _isActive, value); + } + + [JsonIgnore] + public string DisplayName => string.IsNullOrWhiteSpace(ModelName) ? ServiceType : ModelName; + + public PasteAIProviderDefinition Clone() + { + return new PasteAIProviderDefinition + { + Id = Id, + ServiceType = ServiceType, + ModelName = ModelName, + EndpointUrl = EndpointUrl, + ApiVersion = ApiVersion, + DeploymentName = DeploymentName, + ModelPath = ModelPath, + SystemPrompt = SystemPrompt, + ModerationEnabled = ModerationEnabled, + EnableAdvancedAI = EnableAdvancedAI, + IsLocalModel = IsLocalModel, + IsActive = IsActive, + }; + } + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PeekProperties.cs b/src/settings-ui/Settings.UI.Library/PeekProperties.cs index f81a3bc9a6..e6eea746d6 100644 --- a/src/settings-ui/Settings.UI.Library/PeekProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PeekProperties.cs @@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library AlwaysRunNotElevated = new BoolProperty(true); CloseAfterLosingFocus = new BoolProperty(false); ConfirmFileDelete = new BoolProperty(true); + EnableSpaceToActivate = new BoolProperty(true); // Toggle is ON by default for new users. No impact on existing users. } public HotkeySettings ActivationShortcut { get; set; } @@ -29,6 +30,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library public BoolProperty ConfirmFileDelete { get; set; } + public BoolProperty EnableSpaceToActivate { get; set; } + public override string ToString() => JsonSerializer.Serialize(this); } } diff --git a/src/settings-ui/Settings.UI.Library/PeekSettings.cs b/src/settings-ui/Settings.UI.Library/PeekSettings.cs index 73993c72fa..8bc4f6ee76 100644 --- a/src/settings-ui/Settings.UI.Library/PeekSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PeekSettings.cs @@ -15,7 +15,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library public class PeekSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Peek"; - public const string ModuleVersion = "0.0.1"; + public const string InitialModuleVersion = "0.0.1"; + public const string SpaceActivationIntroducedVersion = "0.0.2"; + public const string CurrentModuleVersion = SpaceActivationIntroducedVersion; private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions { @@ -28,7 +30,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public PeekSettings() { Name = ModuleName; - Version = ModuleVersion; + Version = CurrentModuleVersion; Properties = new PeekProperties(); } @@ -54,6 +56,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library public bool UpgradeSettingsConfiguration() { + if (string.IsNullOrEmpty(Version) || + Version.Equals(InitialModuleVersion, StringComparison.OrdinalIgnoreCase)) + { + Version = CurrentModuleVersion; + Properties.EnableSpaceToActivate.Value = false; + return true; + } + return false; } diff --git a/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs b/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs index 316fbfd626..10ebf74314 100644 --- a/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs +++ b/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs @@ -653,11 +653,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library return (false, "General_SettingsBackupAndRestore_InvalidBackupLocation", "Error", lastBackupExists, "\n" + appBasePath); } - var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir); - if (!dirExists) + // Only create the backup directory if this is not a dry run + if (!dryRun) { - Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}"); - return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir); + var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir); + if (!dirExists) + { + Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}"); + return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir); + } } // get data needed for process @@ -717,12 +721,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library var relativePath = currentFile.Value.Substring(appBasePath.Length + 1); var backupFullPath = Path.Combine(fullBackupDir, relativePath); - TryCreateDirectory(fullBackupDir); - TryCreateDirectory(Path.GetDirectoryName(backupFullPath)); - Logger.LogInfo($"BackupSettings writing, {backupFullPath}, dryRun:{dryRun}."); if (!dryRun) { + TryCreateDirectory(fullBackupDir); + TryCreateDirectory(Path.GetDirectoryName(backupFullPath)); File.WriteAllText(backupFullPath, currentSettingsFileToBackup); } } diff --git a/src/settings-ui/Settings.UI.Library/ShortcutConflictProperties.cs b/src/settings-ui/Settings.UI.Library/ShortcutConflictProperties.cs new file mode 100644 index 0000000000..7ce5fb5b1f --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/ShortcutConflictProperties.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class ShortcutConflictProperties + { + [JsonPropertyName("ignored_shortcuts")] + public List IgnoredShortcuts { get; set; } + + public ShortcutConflictProperties() + { + IgnoredShortcuts = new List(); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs new file mode 100644 index 0000000000..3d6d781d03 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class SndCursorWrapSettings + { + [JsonPropertyName("CursorWrap")] + public CursorWrapSettings CursorWrap { get; set; } + + public SndCursorWrapSettings() + { + } + + public SndCursorWrapSettings(CursorWrapSettings settings) + { + CursorWrap = settings; + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs b/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs new file mode 100644 index 0000000000..814ab5a6b1 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class SndLightSwitchSettings + { + [JsonPropertyName("LightSwitch")] + public LightSwitchSettings Settings { get; set; } + + public SndLightSwitchSettings() + { + } + + public SndLightSwitchSettings(LightSwitchSettings settings) + { + Settings = settings; + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs index aaaa80f9f7..1bca1b573a 100644 --- a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs @@ -81,10 +81,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("AnimnateZoom")] public BoolProperty AnimateZoom { get; set; } + public BoolProperty SmoothImage { get; set; } + public IntProperty ZoominSliderLevel { get; set; } public IntProperty RecordScaling { get; set; } + public StringProperty RecordFormat { get; set; } + public BoolProperty CaptureAudio { get; set; } public StringProperty MicrophoneDeviceId { get; set; } diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs b/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs index a76822a8f9..b270ec0178 100644 --- a/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs @@ -181,8 +181,6 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder // Define namespaces XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; XNamespace controls = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; - XNamespace labs = "using:CommunityToolkit.Labs.WinUI"; - XNamespace winui = "using:CommunityToolkit.WinUI.UI.Controls"; // Extract SettingsPageControl elements var settingsPageElements = doc.Descendants() diff --git a/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs b/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs deleted file mode 100644 index aabe2aff53..0000000000 --- a/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; - -namespace Microsoft.PowerToys.Settings.UI.Activation -{ - // For more information on understanding and extending activation flow see - // https://github.com/Microsoft/WindowsTemplateStudio/blob/master/docs/activation.md - internal abstract class ActivationHandler - { - public abstract bool CanHandle(object args); - - public abstract Task HandleAsync(object args); - } - - [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "abstract T and abstract")] - internal abstract class ActivationHandler : ActivationHandler - where T : class - { - public override async Task HandleAsync(object args) - { - await HandleInternalAsync(args as T).ConfigureAwait(false); - } - - public override bool CanHandle(object args) - { - // CanHandle checks the args is of type you have configured - return args is T && CanHandleInternal(args as T); - } - - // Override this method to add the activation logic in your activation handler - protected abstract Task HandleInternalAsync(T args); - - // You can override this method to add extra validation on activation args - // to determine if your ActivationHandler should handle this activation args - protected virtual bool CanHandleInternal(T args) - { - return true; - } - } -} diff --git a/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs b/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs deleted file mode 100644 index 946fab205c..0000000000 --- a/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading.Tasks; - -using Microsoft.PowerToys.Settings.UI.Services; -using Windows.ApplicationModel.Activation; - -namespace Microsoft.PowerToys.Settings.UI.Activation -{ - internal sealed class DefaultActivationHandler : ActivationHandler - { - private readonly Type navElement; - - public DefaultActivationHandler(Type navElement) - { - this.navElement = navElement; - } - - protected override async Task HandleInternalAsync(IActivatedEventArgs args) - { - // When the navigation stack isn't restored, navigate to the first page and configure - // the new page by passing required information in the navigation parameter - object arguments = null; - if (args is LaunchActivatedEventArgs launchArgs) - { - arguments = launchArgs.Arguments; - } - - NavigationService.Navigate(navElement, arguments); - await Task.CompletedTask.ConfigureAwait(false); - } - - protected override bool CanHandleInternal(IActivatedEventArgs args) - { - // None of the ActivationHandlers has handled the app activation - return NavigationService.Frame.Content == null && navElement != null; - } - } -} diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png new file mode 100644 index 0000000000..c32f1e309a Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png index 6cdb55cb66..581c317518 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png new file mode 100644 index 0000000000..d4ce00c74a Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg new file mode 100644 index 0000000000..7497187ad7 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg new file mode 100644 index 0000000000..e6fd7121b2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg new file mode 100644 index 0000000000..53747d557d --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg new file mode 100644 index 0000000000..56a5fe461b --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg new file mode 100644 index 0000000000..ce2471552e --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg new file mode 100644 index 0000000000..e44dda654d --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg new file mode 100644 index 0000000000..301a40fd55 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg new file mode 100644 index 0000000000..87aacb3a4f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg new file mode 100644 index 0000000000..f72a3c64d1 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg new file mode 100644 index 0000000000..fafc16b59f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png index 7e9a2da1a8..69ed506e99 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png index 15568110cc..14d5e71d53 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png index 83d1fbc553..99a8f64ed4 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Background.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Background.png new file mode 100644 index 0000000000..929a637d34 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Background.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Hero.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Hero.png new file mode 100644 index 0000000000..a8889dcc05 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Hero.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png new file mode 100644 index 0000000000..3a98b7f3e2 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png new file mode 100644 index 0000000000..1532531a86 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Converters/BoolToKeyVisualStateConverter.cs b/src/settings-ui/Settings.UI/Converters/BoolToKeyVisualStateConverter.cs new file mode 100644 index 0000000000..04a62b02c7 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/BoolToKeyVisualStateConverter.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Controls; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class BoolToKeyVisualStateConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is bool b && parameter is string param) + { + if (b && param == "Warning") + { + return State.Warning; + } + else if (b && param == "Error") + { + return State.Error; + } + else + { + return State.Normal; + } + } + else + { + return State.Normal; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs b/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs new file mode 100644 index 0000000000..27689435cc --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class EnumToBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value == null || parameter == null) + { + return false; + } + + // Get the enum value as string + var enumString = value.ToString(); + var parameterString = parameter.ToString(); + + return enumString.Equals(parameterString, StringComparison.OrdinalIgnoreCase); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs new file mode 100644 index 0000000000..7d632906c2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public partial class ServiceTypeToIconConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string serviceType || string.IsNullOrWhiteSpace(serviceType)) + { + return new ImageIcon { Source = new SvgImageSource(new Uri(AIServiceTypeRegistry.GetIconPath(AIServiceType.OpenAI))) }; + } + + var iconPath = AIServiceTypeRegistry.GetIconPath(serviceType); + return new ImageIcon { Source = new SvgImageSource(new Uri(iconPath)) }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/src/settings-ui/Settings.UI/Converters/StringToDouble.cs b/src/settings-ui/Settings.UI/Converters/StringToDouble.cs new file mode 100644 index 0000000000..fae0618467 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/StringToDouble.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class StringToDoubleConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double result)) + { + return result; + } + + return 0.0; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is double d) + { + return d.ToString(CultureInfo.InvariantCulture); + } + + return "0"; + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs b/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs new file mode 100644 index 0000000000..496c96959b --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public sealed partial class TimeSpanToFriendlyTimeConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is TimeSpan time) + { + return TimeSpanHelper.Convert(time); + } + + return string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => new NotImplementedException(); +} diff --git a/src/settings-ui/Settings.UI/Helpers/HotkeyConflictIgnoreHelper.cs b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictIgnoreHelper.cs new file mode 100644 index 0000000000..d2e737180a --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictIgnoreHelper.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Views; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + /// + /// Static helper class to manage and check hotkey conflict ignore settings + /// + public static class HotkeyConflictIgnoreHelper + { + private static readonly ISettingsRepository _generalSettingsRepository; + private static readonly ISettingsUtils _settingsUtils; + + static HotkeyConflictIgnoreHelper() + { + _settingsUtils = new SettingsUtils(); + _generalSettingsRepository = SettingsRepository.GetInstance(_settingsUtils); + } + + /// + /// Ensures ignored conflict properties are initialized + /// + private static void EnsureInitialized() + { + var settings = _generalSettingsRepository.SettingsConfig; + if (settings.IgnoredConflictProperties == null) + { + settings.IgnoredConflictProperties = new ShortcutConflictProperties(); + SaveSettings(); + } + } + + /// + /// Checks if a specific hotkey setting is configured to ignore conflicts + /// + /// The hotkey settings to check + /// True if the hotkey is set to ignore conflicts, false otherwise + public static bool IsIgnoringConflicts(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return false; + } + + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + return settings.IgnoredConflictProperties.IgnoredShortcuts + .Any(h => AreHotkeySettingsEqual(h, hotkeySettings)); + } + catch (Exception ex) + { + Logger.LogError($"Error checking if hotkey is ignoring conflicts: {ex.Message}"); + return false; + } + } + + /// + /// Adds a hotkey setting to the ignored shortcuts list + /// + /// The hotkey settings to add to the ignored list + /// True if successfully added, false if it was already ignored or on error + public static bool AddToIgnoredList(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return false; + } + + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + + // Check if already ignored (avoid duplicates) + if (IsIgnoringConflicts(hotkeySettings)) + { + Logger.LogInfo($"Hotkey already in ignored list: {hotkeySettings}"); + return false; + } + + // Add to ignored list + settings.IgnoredConflictProperties.IgnoredShortcuts.Add(hotkeySettings); + SaveSettings(); + + Logger.LogInfo($"Added hotkey to ignored list: {hotkeySettings}"); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Error adding hotkey to ignored list: {ex.Message}"); + return false; + } + } + + /// + /// Removes a hotkey setting from the ignored shortcuts list + /// + /// The hotkey settings to remove from the ignored list + /// True if successfully removed, false if it wasn't in the list or on error + public static bool RemoveFromIgnoredList(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return false; + } + + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + var ignoredShortcut = settings.IgnoredConflictProperties.IgnoredShortcuts + .FirstOrDefault(h => AreHotkeySettingsEqual(h, hotkeySettings)); + + if (ignoredShortcut != null) + { + settings.IgnoredConflictProperties.IgnoredShortcuts.Remove(ignoredShortcut); + SaveSettings(); + + Logger.LogInfo($"Removed hotkey from ignored list: {ignoredShortcut}"); + return true; + } + + Logger.LogInfo($"Hotkey not found in ignored list: {hotkeySettings}"); + return false; + } + catch (Exception ex) + { + Logger.LogError($"Error removing hotkey from ignored list: {ex.Message}"); + return false; + } + } + + /// + /// Gets all hotkey settings that are currently being ignored + /// + /// List of ignored hotkey settings + public static List GetAllIgnoredShortcuts() + { + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + return new List(settings.IgnoredConflictProperties.IgnoredShortcuts); + } + catch (Exception ex) + { + Logger.LogError($"Error getting ignored shortcuts: {ex.Message}"); + return new List(); + } + } + + /// + /// Clears all ignored shortcuts from the list + /// + /// True if successfully cleared, false on error + public static bool ClearAllIgnoredShortcuts() + { + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + var count = settings.IgnoredConflictProperties.IgnoredShortcuts.Count; + settings.IgnoredConflictProperties.IgnoredShortcuts.Clear(); + SaveSettings(); + + Logger.LogInfo($"Cleared all {count} ignored shortcuts"); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Error clearing ignored shortcuts: {ex.Message}"); + return false; + } + } + + /// + /// Compares two HotkeySettings for equality + /// + /// First hotkey settings + /// Second hotkey settings + /// True if they represent the same shortcut, false otherwise + private static bool AreHotkeySettingsEqual(HotkeySettings hotkey1, HotkeySettings hotkey2) + { + if (hotkey1 == null || hotkey2 == null) + { + return false; + } + + return hotkey1.Win == hotkey2.Win && + hotkey1.Ctrl == hotkey2.Ctrl && + hotkey1.Alt == hotkey2.Alt && + hotkey1.Shift == hotkey2.Shift && + hotkey1.Code == hotkey2.Code; + } + + /// + /// Saves the general settings using PowerToys standard settings persistence + /// + private static void SaveSettings() + { + try + { + var settings = _generalSettingsRepository.SettingsConfig; + + // Send IPC message to notify runner of changes (this is thread-safe) + var outgoing = new OutGoingGeneralSettings(settings); + ShellPage.SendDefaultIPCMessage(outgoing.ToString()); + ShellPage.ShellHandler?.SignalGeneralDataUpdate(); + } + catch (Exception ex) + { + Logger.LogError($"Error saving shortcut conflict settings: {ex.Message}"); + Logger.LogError($"Stack trace: {ex.StackTrace}"); + throw; + } + } + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs index f21ca2bfac..89b22d8164 100644 --- a/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs @@ -22,7 +22,8 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.FindMyMouse: case ModuleType.MouseHighlighter: case ModuleType.MouseJump: - case ModuleType.MousePointerCrosshairs: return $"MouseUtils_{moduleType}/Header"; + case ModuleType.MousePointerCrosshairs: + case ModuleType.CursorWrap: return $"MouseUtils_{moduleType}/Header"; default: return $"{moduleType}/ModuleTitle"; } } @@ -52,6 +53,8 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.CmdPal: return generalSettingsConfig.Enabled.CmdPal; case ModuleType.ColorPicker: return generalSettingsConfig.Enabled.ColorPicker; case ModuleType.CropAndLock: return generalSettingsConfig.Enabled.CropAndLock; + case ModuleType.CursorWrap: return generalSettingsConfig.Enabled.CursorWrap; + case ModuleType.LightSwitch: return generalSettingsConfig.Enabled.LightSwitch; case ModuleType.EnvironmentVariables: return generalSettingsConfig.Enabled.EnvironmentVariables; case ModuleType.FancyZones: return generalSettingsConfig.Enabled.FancyZones; case ModuleType.FileLocksmith: return generalSettingsConfig.Enabled.FileLocksmith; @@ -88,6 +91,8 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.CmdPal: generalSettingsConfig.Enabled.CmdPal = isEnabled; break; case ModuleType.ColorPicker: generalSettingsConfig.Enabled.ColorPicker = isEnabled; break; case ModuleType.CropAndLock: generalSettingsConfig.Enabled.CropAndLock = isEnabled; break; + case ModuleType.CursorWrap: generalSettingsConfig.Enabled.CursorWrap = isEnabled; break; + case ModuleType.LightSwitch: generalSettingsConfig.Enabled.LightSwitch = isEnabled; break; case ModuleType.EnvironmentVariables: generalSettingsConfig.Enabled.EnvironmentVariables = isEnabled; break; case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break; case ModuleType.FileLocksmith: generalSettingsConfig.Enabled.FileLocksmith = isEnabled; break; @@ -123,6 +128,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.CmdPal: return GPOWrapper.GetConfiguredCmdPalEnabledValue(); case ModuleType.ColorPicker: return GPOWrapper.GetConfiguredColorPickerEnabledValue(); case ModuleType.CropAndLock: return GPOWrapper.GetConfiguredCropAndLockEnabledValue(); + case ModuleType.CursorWrap: return GPOWrapper.GetConfiguredCursorWrapEnabledValue(); case ModuleType.EnvironmentVariables: return GPOWrapper.GetConfiguredEnvironmentVariablesEnabledValue(); case ModuleType.FancyZones: return GPOWrapper.GetConfiguredFancyZonesEnabledValue(); case ModuleType.FileLocksmith: return GPOWrapper.GetConfiguredFileLocksmithEnabledValue(); @@ -159,6 +165,8 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers ModuleType.CmdPal => typeof(CmdPalPage), ModuleType.ColorPicker => typeof(ColorPickerPage), ModuleType.CropAndLock => typeof(CropAndLockPage), + ModuleType.CursorWrap => typeof(MouseUtilsPage), + ModuleType.LightSwitch => typeof(LightSwitchPage), ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage), ModuleType.FancyZones => typeof(FancyZonesPage), ModuleType.FileLocksmith => typeof(FileLocksmithPage), diff --git a/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs b/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs new file mode 100644 index 0000000000..95308ec67e --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Helpers; + +public static class TimeSpanHelper +{ + public static string Convert(TimeSpan? time) + { + if (time is not TimeSpan ts) + { + return string.Empty; + } + + // If user passed in a negative TimeSpan, normalize + if (ts < TimeSpan.Zero) + { + ts = ts.Duration(); + } + + // Map the TimeSpan to a DateTime on today's date + var dt = DateTime.Today.Add(ts); + + // This pattern automatically respects system 12/24-hour setting + string pattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern; + + return dt.ToString(pattern, CultureInfo.CurrentCulture); + } +} diff --git a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs index fbf689b9de..fd684168b0 100644 --- a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs +++ b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs @@ -20,6 +20,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums FileExplorer, ImageResizer, KBM, + LightSwitch, MouseUtils, MouseWithoutBorders, Peek, diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index 18ee80a5bf..a6751adb98 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -20,8 +20,17 @@ PowerToys.Settings.pri + + + + + + + + + @@ -52,18 +61,26 @@ + + + + PreserveNewest + + + + - + @@ -104,6 +121,7 @@ + @@ -156,17 +174,28 @@ Always - + + + MSBuild:Compile MSBuild:Compile + + MSBuild:Compile + MSBuild:Compile MSBuild:Compile + + MSBuild:Compile + + + MSBuild:Compile + @@ -181,8 +210,8 @@ - + - \ No newline at end of file + diff --git a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs index bd72be5f8c..838149a04e 100644 --- a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs +++ b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs @@ -10,6 +10,7 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; +using Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.SerializationContext; @@ -22,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext; [JsonSerializable(typeof(FileLocksmithSettings))] [JsonSerializable(typeof(FindMyMouseSettings))] [JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(LightSwitchSettings))] [JsonSerializable(typeof(MeasureToolSettings))] [JsonSerializable(typeof(MouseHighlighterSettings))] [JsonSerializable(typeof(MouseJumpSettings))] @@ -33,6 +35,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext; [JsonSerializable(typeof(PowerOcrSettings))] [JsonSerializable(typeof(PowerOcrSettings))] [JsonSerializable(typeof(RegistryPreviewSettings))] +[JsonSerializable(typeof(ShortcutConflictProperties))] [JsonSerializable(typeof(ShortcutGuideSettings))] [JsonSerializable(typeof(WINDOWPLACEMENT))] [JsonSerializable(typeof(WorkspacesSettings))] diff --git a/src/settings-ui/Settings.UI/Services/ActivationService.cs b/src/settings-ui/Settings.UI/Services/ActivationService.cs deleted file mode 100644 index 86ad2e4d7c..0000000000 --- a/src/settings-ui/Settings.UI/Services/ActivationService.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using Microsoft.PowerToys.Settings.UI.Activation; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Windows.ApplicationModel.Activation; - -namespace Microsoft.PowerToys.Settings.UI.Services -{ - // For more information on understanding and extending activation flow see - // https://github.com/Microsoft/WindowsTemplateStudio/blob/master/docs/activation.md - internal sealed class ActivationService - { - private readonly App app; - private readonly Type defaultNavItem; - private Lazy shell; - - private object lastActivationArgs; - - public ActivationService(App app, Type defaultNavItem, Lazy shell = null) - { - this.app = app; - this.shell = shell; - this.defaultNavItem = defaultNavItem; - } - - public async Task ActivateAsync(object activationArgs) - { - if (IsInteractive(activationArgs)) - { - // Initialize services that you need before app activation - // take into account that the splash screen is shown while this code runs. - await InitializeAsync().ConfigureAwait(false); - - // Do not repeat app initialization when the Window already has content, - // just ensure that the window is active - if (Window.Current.Content == null) - { - // Create a Shell or Frame to act as the navigation context - Window.Current.Content = shell?.Value ?? new Frame(); - } - } - - // Depending on activationArgs one of ActivationHandlers or DefaultActivationHandler - // will navigate to the first page - await HandleActivationAsync(activationArgs).ConfigureAwait(false); - lastActivationArgs = activationArgs; - - if (IsInteractive(activationArgs)) - { - // Ensure the current window is active - Window.Current.Activate(); - - // Tasks after activation - await StartupAsync().ConfigureAwait(false); - } - } - - private static async Task InitializeAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - } - - private async Task HandleActivationAsync(object activationArgs) - { - var activationHandler = GetActivationHandlers() - .FirstOrDefault(h => h.CanHandle(activationArgs)); - - if (activationHandler != null) - { - await activationHandler.HandleAsync(activationArgs).ConfigureAwait(false); - } - - if (IsInteractive(activationArgs)) - { - var defaultHandler = new DefaultActivationHandler(defaultNavItem); - if (defaultHandler.CanHandle(activationArgs)) - { - await defaultHandler.HandleAsync(activationArgs).ConfigureAwait(false); - } - } - } - - private static async Task StartupAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - } - - private static IEnumerable GetActivationHandlers() - { - yield break; - } - - private static bool IsInteractive(object args) - { - return args is IActivatedEventArgs; - } - } -} diff --git a/src/settings-ui/Settings.UI/Services/NavigationService.cs b/src/settings-ui/Settings.UI/Services/NavigationService.cs index b70976bd01..d7c408208b 100644 --- a/src/settings-ui/Settings.UI/Services/NavigationService.cs +++ b/src/settings-ui/Settings.UI/Services/NavigationService.cs @@ -24,12 +24,6 @@ namespace Microsoft.PowerToys.Settings.UI.Services { get { - if (frame == null) - { - frame = Window.Current.Content as Frame; - RegisterFrameEvents(); - } - return frame; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml index d4a95313d0..f63ccdf3a6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml @@ -17,6 +17,7 @@ + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index d5bd0977e1..19cd75b022 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -417,6 +417,7 @@ namespace Microsoft.PowerToys.Settings.UI case "Awake": return typeof(AwakePage); case "CmdNotFound": return typeof(CmdNotFoundPage); case "ColorPicker": return typeof(ColorPickerPage); + case "LightSwitch": return typeof(LightSwitchPage); case "FancyZones": return typeof(FancyZonesPage); case "FileLocksmith": return typeof(FileLocksmithPage); case "Run": return typeof(PowerLauncherPage); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml deleted file mode 100644 index c077042d96..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml.cs deleted file mode 100644 index 24a0d0e448..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml.cs +++ /dev/null @@ -1,59 +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.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Windows.UI; - -namespace Microsoft.PowerToys.Settings.UI.Controls -{ - public sealed partial class AlphaColorPickerButton : UserControl - { - private Color _selectedColor; - - public Color SelectedColor - { - get - { - return _selectedColor; - } - - set - { - if (_selectedColor != value) - { - _selectedColor = value; - SetValue(SelectedColorProperty, value); - } - } - } - - public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register("SelectedColor", typeof(Color), typeof(AlphaColorPickerButton), new PropertyMetadata(null)); - - public AlphaColorPickerButton() - { - this.InitializeComponent(); - IsEnabledChanged -= AlphaColorPickerButton_IsEnabledChanged; - SetEnabledState(); - IsEnabledChanged += AlphaColorPickerButton_IsEnabledChanged; - } - - private void AlphaColorPickerButton_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) - { - SetEnabledState(); - } - - private void SetEnabledState() - { - if (this.IsEnabled) - { - ColorPreviewBorder.Opacity = 1; - } - else - { - ColorPreviewBorder.Opacity = 0.2; - } - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml index ea6d1c9f22..743ced3204 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml @@ -9,7 +9,7 @@ xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> @@ -61,7 +61,7 @@ - diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml index 10a1e01236..4a46b7dc29 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml @@ -27,9 +27,9 @@ (bool)GetValue(IsAlphaEnabledProperty); + set => SetValue(IsAlphaEnabledProperty, value); + } public ColorPickerButton() { diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml index 69a7a1084d..44470ebbc1 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml @@ -8,18 +8,21 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - - + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs index 7195b159e1..d7806f17ea 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs @@ -47,12 +47,24 @@ namespace Microsoft.PowerToys.Settings.UI.Controls int count = 0; if (AllHotkeyConflictsData.InAppConflicts != null) { - count += AllHotkeyConflictsData.InAppConflicts.Count; + foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts) + { + if (!inAppConflict.ConflictIgnored) + { + count++; + } + } } if (AllHotkeyConflictsData.SystemConflicts != null) { - count += AllHotkeyConflictsData.SystemConflicts.Count; + foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts) + { + if (!systemConflict.ConflictIgnored) + { + count++; + } + } } return count; @@ -95,7 +107,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls OnPropertyChanged(nameof(HasConflicts)); // Update visibility based on conflict count - Visibility = HasConflicts ? Visibility.Visible : Visibility.Collapsed; + if (HasConflicts) + { + VisualStateManager.GoToState(this, "ConflictState", true); + } + else + { + VisualStateManager.GoToState(this, "NoConflictState", true); + } if (!_telemetryEventSent && HasConflicts) { @@ -119,13 +138,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls InitializeComponent(); DataContext = this; - // Initially hide the control if no conflicts - Visibility = HasConflicts ? Visibility.Visible : Visibility.Collapsed; + UpdateProperties(); } private void ShortcutConflictBtn_Click(object sender, RoutedEventArgs e) { - if (AllHotkeyConflictsData == null || !HasConflicts) + if (AllHotkeyConflictsData == null) { return; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml index 46f8d4f962..11d9b5f7b0 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml @@ -53,34 +53,22 @@ - - - - - - - - - - + + + + + + + @@ -97,22 +85,40 @@ - + - + + + + - + @@ -137,15 +143,15 @@ + Background="Transparent" + BorderThickness="0,1,0,0" + CornerRadius="0" + IsEnabled="{x:Bind ShouldShowSysConflict, Mode=OneWay}"> - + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs index 5bcc282261..b9bee4ff08 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs @@ -14,6 +14,7 @@ using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Windows.Graphics; using WinUIEx; @@ -21,8 +22,6 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard { public sealed partial class ShortcutConflictWindow : WindowEx { - public ShortcutConflictViewModel DataContext { get; } - public ShortcutConflictViewModel ViewModel { get; private set; } public ShortcutConflictWindow() @@ -33,14 +32,17 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); - DataContext = ViewModel; InitializeComponent(); + // Set DataContext on the root Grid instead of the Window + RootGrid.DataContext = ViewModel; + this.Activated += Window_Activated_SetIcon; // Set localized window title var resourceLoader = ResourceLoaderInstance.ResourceLoader; - this.ExtendsContentIntoTitleBar = true; + ExtendsContentIntoTitleBar = true; + SetTitleBar(titleBar); this.Title = resourceLoader.GetString("ShortcutConflictWindow_Title"); this.CenterOnScreen(); @@ -74,6 +76,54 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard } } + private void OnIgnoreConflictClicked(object sender, RoutedEventArgs e) + { + if (sender is CheckBox checkBox && checkBox.DataContext is HotkeyConflictGroupData conflictGroup) + { + // The Click event only fires from user interaction, not programmatic changes + if (checkBox.IsChecked == true) + { + IgnoreConflictGroup(conflictGroup); + } + else + { + UnignoreConflictGroup(conflictGroup); + } + } + } + + private void IgnoreConflictGroup(HotkeyConflictGroupData conflictGroup) + { + try + { + // Ignore all hotkey settings in this conflict group + if (conflictGroup.Modules != null) + { + HotkeySettings hotkey = new(conflictGroup.Hotkey.Win, conflictGroup.Hotkey.Ctrl, conflictGroup.Hotkey.Alt, conflictGroup.Hotkey.Shift, conflictGroup.Hotkey.Key); + ViewModel.IgnoreShortcut(hotkey); + } + } + catch + { + } + } + + private void UnignoreConflictGroup(HotkeyConflictGroupData conflictGroup) + { + try + { + // Unignore all hotkey settings in this conflict group + if (conflictGroup.Modules != null) + { + HotkeySettings hotkey = new(conflictGroup.Hotkey.Win, conflictGroup.Hotkey.Ctrl, conflictGroup.Hotkey.Alt, conflictGroup.Hotkey.Shift, conflictGroup.Hotkey.Key); + ViewModel.UnignoreShortcut(hotkey); + } + } + catch + { + } + } + private void WindowEx_Closed(object sender, WindowEventArgs args) { ViewModel?.Dispose(); @@ -82,10 +132,7 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args) { // Set window icon - var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - WindowId windowId = Win32Interop.GetWindowIdFromWindow(hWnd); - AppWindow appWindow = AppWindow.GetFromWindowId(windowId); - appWindow.SetIcon("Assets\\Settings\\icon.ico"); + AppWindow.SetIcon("Assets\\Settings\\icon.ico"); } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml new file mode 100644 index 0000000000..73a862f00a --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml @@ -0,0 +1,28 @@ + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml.cs new file mode 100644 index 0000000000..8942d5c4db --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml.cs @@ -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 System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public sealed partial class GPOInfoControl : ContentControl +{ + public static readonly DependencyProperty ShowWarningProperty = + DependencyProperty.Register( + nameof(ShowWarning), + typeof(bool), + typeof(GPOInfoControl), + new PropertyMetadata(false, OnShowWarningPropertyChanged)); + + public bool ShowWarning + { + get => (bool)GetValue(ShowWarningProperty); + set => SetValue(ShowWarningProperty, value); + } + + private static void OnShowWarningPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is GPOInfoControl gpoInfoControl) + { + if (gpoInfoControl.ShowWarning) + { + gpoInfoControl.IsEnabled = false; + } + } + } + + public GPOInfoControl() + { + DefaultStyleKey = typeof(GPOInfoControl); + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml index 931286ceaf..9c820ba12c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml @@ -10,10 +10,10 @@ - + - - + + @@ -33,6 +33,7 @@ HorizontalAlignment="{TemplateBinding HorizontalAlignment}" VerticalAlignment="{TemplateBinding VerticalAlignment}" Background="{TemplateBinding Background}" + BackgroundSizing="{TemplateBinding BackgroundSizing}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}"> @@ -63,10 +64,18 @@ - + + + + + + + + + @@ -120,6 +129,11 @@ + + + + + @@ -132,7 +146,6 @@ x:Key="AccentKeyVisualStyle" BasedOn="{StaticResource DefaultKeyVisualStyle}" TargetType="local:KeyVisual"> - @@ -148,6 +161,7 @@ VerticalAlignment="{TemplateBinding VerticalAlignment}" AutomationProperties.AccessibilityView="Raw" Background="{TemplateBinding Background}" + BackgroundSizing="{TemplateBinding BackgroundSizing}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}"> @@ -177,10 +191,18 @@ - + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml.cs index b638c32f2b..87dc9a4c21 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml.cs @@ -12,12 +12,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls [TemplateVisualState(Name = NormalState, GroupName = "CommonStates")] [TemplateVisualState(Name = DisabledState, GroupName = "CommonStates")] [TemplateVisualState(Name = InvalidState, GroupName = "CommonStates")] + [TemplateVisualState(Name = WarningState, GroupName = "CommonStates")] public sealed partial class KeyVisual : Control { private const string KeyPresenter = "KeyPresenter"; private const string NormalState = "Normal"; private const string DisabledState = "Disabled"; private const string InvalidState = "Invalid"; + private const string WarningState = "Warning"; private KeyCharPresenter _keyPresenter; public object Content @@ -28,13 +30,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(nameof(Content), typeof(object), typeof(KeyVisual), new PropertyMetadata(default(string), OnContentChanged)); - public bool IsInvalid + public State State { - get => (bool)GetValue(IsInvalidProperty); - set => SetValue(IsInvalidProperty, value); + get => (State)GetValue(StateProperty); + set => SetValue(StateProperty, value); } - public static readonly DependencyProperty IsInvalidProperty = DependencyProperty.Register(nameof(IsInvalid), typeof(bool), typeof(KeyVisual), new PropertyMetadata(false, OnIsInvalidChanged)); + public static readonly DependencyProperty StateProperty = DependencyProperty.Register(nameof(State), typeof(State), typeof(KeyVisual), new PropertyMetadata(State.Normal, OnStateChanged)); public bool RenderKeyAsGlyph { @@ -64,7 +66,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls ((KeyVisual)d).SetVisualStates(); } - private static void OnIsInvalidChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { ((KeyVisual)d).SetVisualStates(); } @@ -73,10 +75,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { if (this != null) { - if (IsInvalid) + if (State == State.Error) { VisualStateManager.GoToState(this, InvalidState, true); } + else if (State == State.Warning) + { + VisualStateManager.GoToState(this, WarningState, true); + } else if (!IsEnabled) { VisualStateManager.GoToState(this, DisabledState, true); @@ -177,4 +183,11 @@ namespace Microsoft.PowerToys.Settings.UI.Controls SetVisualStates(); } } + + public enum State + { + Normal, + Error, + Warning, + } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml new file mode 100644 index 0000000000..adc802eba9 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs new file mode 100644 index 0000000000..400074f9d3 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs @@ -0,0 +1,447 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using LanguageModelProvider; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public sealed partial class FoundryLocalModelPicker : UserControl +{ + private INotifyCollectionChanged _cachedModelsSubscription; + private INotifyCollectionChanged _downloadableModelsSubscription; + private bool _suppressSelection; + + public FoundryLocalModelPicker() + { + InitializeComponent(); + Loaded += (_, _) => UpdateVisualStates(); + } + + public delegate void ModelSelectionChangedEventHandler(object sender, ModelDetails model); + + public delegate void DownloadRequestedEventHandler(object sender, object payload); + + public delegate void LoadRequestedEventHandler(object sender); + + public event ModelSelectionChangedEventHandler SelectionChanged; + + public event LoadRequestedEventHandler LoadRequested; + + public IEnumerable CachedModels + { + get => (IEnumerable)GetValue(CachedModelsProperty); + set => SetValue(CachedModelsProperty, value); + } + + public static readonly DependencyProperty CachedModelsProperty = + DependencyProperty.Register(nameof(CachedModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnCachedModelsChanged)); + + public IEnumerable DownloadableModels + { + get => (IEnumerable)GetValue(DownloadableModelsProperty); + set => SetValue(DownloadableModelsProperty, value); + } + + public static readonly DependencyProperty DownloadableModelsProperty = + DependencyProperty.Register(nameof(DownloadableModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnDownloadableModelsChanged)); + + public ModelDetails SelectedModel + { + get => (ModelDetails)GetValue(SelectedModelProperty); + set => SetValue(SelectedModelProperty, value); + } + + public static readonly DependencyProperty SelectedModelProperty = + DependencyProperty.Register(nameof(SelectedModel), typeof(ModelDetails), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnSelectedModelChanged)); + + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public static readonly DependencyProperty IsLoadingProperty = + DependencyProperty.Register(nameof(IsLoading), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public bool IsAvailable + { + get => (bool)GetValue(IsAvailableProperty); + set => SetValue(IsAvailableProperty, value); + } + + public static readonly DependencyProperty IsAvailableProperty = + DependencyProperty.Register(nameof(IsAvailable), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public string StatusText + { + get => (string)GetValue(StatusTextProperty); + set => SetValue(StatusTextProperty, value); + } + + public static readonly DependencyProperty StatusTextProperty = + DependencyProperty.Register(nameof(StatusText), typeof(string), typeof(FoundryLocalModelPicker), new PropertyMetadata(string.Empty, OnStatePropertyChanged)); + + public bool HasCachedModels => CachedModels?.Any() ?? false; + + public bool HasDownloadableModels => DownloadableModels?.Cast().Any() ?? false; + + public void RequestLoad() + { + if (IsLoading) + { + // Allow refresh requests to continue even if already loading by cancelling via host. + } + else + { + IsLoading = true; + } + + IsAvailable = false; + StatusText = "Loading Foundry Local status..."; + LoadRequested?.Invoke(this); + } + + private static void OnCachedModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToCachedModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnDownloadableModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToDownloadableModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnSelectedModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + if (control._suppressSelection) + { + return; + } + + try + { + control._suppressSelection = true; + if (control.CachedModelsComboBox is not null) + { + control.CachedModelsComboBox.SelectedItem = e.NewValue; + } + } + finally + { + control._suppressSelection = false; + } + + control.UpdateSelectedModelDetails(); + } + + private static void OnStatePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.UpdateVisualStates(); + } + + private void SubscribeToCachedModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_cachedModelsSubscription is not null) + { + _cachedModelsSubscription.CollectionChanged -= CachedModels_CollectionChanged; + _cachedModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += CachedModels_CollectionChanged; + _cachedModelsSubscription = observable; + } + } + + private void SubscribeToDownloadableModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_downloadableModelsSubscription is not null) + { + _downloadableModelsSubscription.CollectionChanged -= DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = observable; + } + } + + private void CachedModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void DownloadableModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void CachedModelsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressSelection) + { + return; + } + + try + { + _suppressSelection = true; + var selected = CachedModelsComboBox.SelectedItem as ModelDetails; + SetValue(SelectedModelProperty, selected); + SelectionChanged?.Invoke(this, selected); + } + finally + { + _suppressSelection = false; + } + + UpdateSelectedModelDetails(); + } + + private void UpdateSelectedModelDetails() + { + if (SelectedModelDetailsPanel is null || SelectedModelDescriptionText is null || SelectedModelTagsPanel is null) + { + return; + } + + if (!HasCachedModels || SelectedModel is not ModelDetails model) + { + SelectedModelDetailsPanel.Visibility = Visibility.Collapsed; + SelectedModelDescriptionText.Text = string.Empty; + SelectedModelTagsPanel.Children.Clear(); + SelectedModelTagsPanel.Visibility = Visibility.Collapsed; + return; + } + + SelectedModelDetailsPanel.Visibility = Visibility.Visible; + SelectedModelDescriptionText.Text = string.IsNullOrWhiteSpace(model.Description) + ? "No description provided." + : model.Description; + + SelectedModelTagsPanel.Children.Clear(); + + AddTag(GetModelSizeText(model.Size)); + AddTag(GetLicenseShortText(model.License), model.License); + + foreach (var deviceTag in GetDeviceTags(model.HardwareAccelerators)) + { + AddTag(deviceTag); + } + + SelectedModelTagsPanel.Visibility = SelectedModelTagsPanel.Children.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + void AddTag(string text, string tooltip = null) + { + if (string.IsNullOrWhiteSpace(text) || SelectedModelTagsPanel is null) + { + return; + } + + Border tag = new(); + if (Resources.TryGetValue("TagBorderStyle", out var borderStyleObj) && borderStyleObj is Style borderStyle) + { + tag.Style = borderStyle; + } + + TextBlock label = new() + { + Text = text, + }; + + if (Resources.TryGetValue("TagTextStyle", out var textStyleObj) && textStyleObj is Style textStyle) + { + label.Style = textStyle; + } + + tag.Child = label; + + if (!string.IsNullOrWhiteSpace(tooltip)) + { + ToolTipService.SetToolTip(tag, new TextBlock + { + Text = tooltip, + TextWrapping = TextWrapping.Wrap, + }); + } + + SelectedModelTagsPanel.Children.Add(tag); + } + } + + private void LaunchFoundryModelListButton_Click(object sender, RoutedEventArgs e) + { + try + { + ProcessStartInfo processInfo = new() + { + FileName = "powershell.exe", + Arguments = "-NoExit -Command \"foundry model list\"", + UseShellExecute = true, + }; + + Process.Start(processInfo); + StatusText = "Opening PowerShell and running 'foundry model list'..."; + } + catch (Exception ex) + { + StatusText = $"Unable to start PowerShell. {ex.Message}"; + Debug.WriteLine($"[FoundryLocalModelPicker] Failed to run 'foundry model list': {ex}"); + } + } + + private void RefreshModelsButton_Click(object sender, RoutedEventArgs e) + { + RequestLoad(); + } + + private void UpdateVisualStates() + { + LoadingIndicator.IsActive = IsLoading; + + if (IsLoading) + { + VisualStateManager.GoToState(this, "ShowLoading", true); + } + else if (!IsAvailable) + { + VisualStateManager.GoToState(this, "ShowNotAvailable", true); + } + else + { + VisualStateManager.GoToState(this, "ShowModels", true); + } + + if (LoadingStatusTextBlock is not null) + { + LoadingStatusTextBlock.Text = string.IsNullOrWhiteSpace(StatusText) + ? "Loading Foundry Local status..." + : StatusText; + } + + NoModelsPanel.Visibility = HasCachedModels ? Visibility.Collapsed : Visibility.Visible; + if (CachedModelsComboBox is not null) + { + CachedModelsComboBox.Visibility = HasCachedModels ? Visibility.Visible : Visibility.Collapsed; + CachedModelsComboBox.IsEnabled = HasCachedModels; + } + + UpdateSelectedModelDetails(); + + Bindings.Update(); + } + + public static string GetModelSizeText(long size) + { + if (size <= 0) + { + return string.Empty; + } + + const long kiloByte = 1024; + const long megaByte = kiloByte * 1024; + const long gigaByte = megaByte * 1024; + + if (size >= gigaByte) + { + return $"{size / (double)gigaByte:0.##} GB"; + } + + if (size >= megaByte) + { + return $"{size / (double)megaByte:0.##} MB"; + } + + if (size >= kiloByte) + { + return $"{size / (double)kiloByte:0.##} KB"; + } + + return $"{size} B"; + } + + public static Visibility GetModelSizeVisibility(long size) + { + return size > 0 ? Visibility.Visible : Visibility.Collapsed; + } + + public static IEnumerable GetDeviceTags(IReadOnlyCollection accelerators) + { + if (accelerators is null || accelerators.Count == 0) + { + return Array.Empty(); + } + + HashSet tags = new(StringComparer.OrdinalIgnoreCase); + + foreach (var accelerator in accelerators) + { + switch (accelerator) + { + case HardwareAccelerator.CPU: + tags.Add("CPU"); + break; + case HardwareAccelerator.GPU: + case HardwareAccelerator.DML: + tags.Add("GPU"); + break; + case HardwareAccelerator.NPU: + case HardwareAccelerator.QNN: + tags.Add("NPU"); + break; + } + } + + return tags.Count > 0 ? tags.ToArray() : Array.Empty(); + } + + public static Visibility GetDeviceVisibility(IReadOnlyCollection accelerators) + { + return GetDeviceTags(accelerators).Any() ? Visibility.Visible : Visibility.Collapsed; + } + + public static string GetLicenseShortText(string license) + { + if (string.IsNullOrWhiteSpace(license)) + { + return string.Empty; + } + + var trimmed = license.Trim(); + int separatorIndex = trimmed.IndexOfAny(['(', '[', ':']); + if (separatorIndex > 0) + { + trimmed = trimmed[..separatorIndex].Trim(); + } + + if (trimmed.Length > 24) + { + trimmed = $"{trimmed[..24].TrimEnd()}…"; + } + + return trimmed; + } + + public static Visibility GetLicenseVisibility(string license) + { + return string.IsNullOrWhiteSpace(license) ? Visibility.Collapsed : Visibility.Visible; + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/PowerAccentShortcutControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/PowerAccentShortcutControl.xaml index 09b2d7d26a..14de03d176 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/PowerAccentShortcutControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/PowerAccentShortcutControl.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" d:DesignHeight="300" d:DesignWidth="400" mc:Ignorable="d"> @@ -35,7 +35,7 @@ - @@ -28,7 +28,7 @@ Style="{StaticResource TitleTextBlockStyle}" Text="{x:Bind ModuleTitle}" /> - + - + @@ -123,7 +123,7 @@ - + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml index d81be4aa6c..a747d71ef0 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml @@ -6,11 +6,14 @@ xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" x:Name="LayoutRoot" d:DesignHeight="300" d:DesignWidth="400" mc:Ignorable="d"> - + + + @@ -78,6 +88,7 @@ + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs index 6b4b9b7957..ba053e1124 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs @@ -12,6 +12,7 @@ using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard; using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; @@ -51,6 +52,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls public static readonly DependencyProperty AllowDisableProperty = DependencyProperty.Register("AllowDisable", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnAllowDisableChanged)); public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnHasConflictChanged)); public static readonly DependencyProperty TooltipProperty = DependencyProperty.Register("Tooltip", typeof(string), typeof(ShortcutControl), new PropertyMetadata(null, OnTooltipChanged)); + public static readonly DependencyProperty KeyVisualShouldShowConflictProperty = DependencyProperty.Register("KeyVisualShouldShowConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false)); + public static readonly DependencyProperty IgnoreConflictProperty = DependencyProperty.Register("IgnoreConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false)); // Dependency property to track the source/context of the ShortcutControl public static readonly DependencyProperty SourceProperty = DependencyProperty.Register("Source", typeof(ShortcutControlSource), typeof(ShortcutControl), new PropertyMetadata(ShortcutControlSource.SettingsPage)); @@ -161,6 +164,18 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set => SetValue(TooltipProperty, value); } + public bool KeyVisualShouldShowConflict + { + get => (bool)GetValue(KeyVisualShouldShowConflictProperty); + set => SetValue(KeyVisualShouldShowConflictProperty, value); + } + + public bool IgnoreConflict + { + get => (bool)GetValue(IgnoreConflictProperty); + set => SetValue(IgnoreConflictProperty, value); + } + public ShortcutControlSource Source { get => (ShortcutControlSource)GetValue(SourceProperty); @@ -241,6 +256,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls // Update the ShortcutControl's conflict properties from HotkeySettings HasConflict = hotkeySettings.HasConflict; Tooltip = hotkeySettings.HasConflict ? hotkeySettings.ConflictDescription : null; + IgnoreConflict = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySettings); + KeyVisualShouldShowConflict = !IgnoreConflict && HasConflict; } else { @@ -257,6 +274,10 @@ namespace Microsoft.PowerToys.Settings.UI.Controls this.Unloaded += ShortcutControl_Unloaded; this.Loaded += ShortcutControl_Loaded; + c.ResetClick += C_ResetClick; + c.ClearClick += C_ClearClick; + c.LearnMoreClick += C_LearnMoreClick; + // We create the Dialog in C# because doing it in XAML is giving WinUI/XAML Island bugs when using dark theme. shortcutDialog = new ContentDialog { @@ -264,11 +285,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls Title = resourceLoader.GetString("Activation_Shortcut_Title"), Content = c, PrimaryButtonText = resourceLoader.GetString("Activation_Shortcut_Save"), - SecondaryButtonText = resourceLoader.GetString("Activation_Shortcut_Reset"), CloseButtonText = resourceLoader.GetString("Activation_Shortcut_Cancel"), DefaultButton = ContentDialogButton.Primary, }; - shortcutDialog.SecondaryButtonClick += ShortcutDialog_Reset; shortcutDialog.RightTapped += ShortcutDialog_Disable; AutomationProperties.SetName(EditButton, resourceLoader.GetString("Activation_Shortcut_Title")); @@ -276,6 +295,16 @@ namespace Microsoft.PowerToys.Settings.UI.Controls OnAllowDisableChanged(this, null); } + private void C_LearnMoreClick(object sender, RoutedEventArgs e) + { + // Close the current shortcut dialog + shortcutDialog.Hide(); + + // Create and show the ShortcutConflictWindow + var conflictWindow = new ShortcutConflictWindow(); + conflictWindow.Activate(); + } + private void UpdateKeyVisualStyles() { if (PreviewKeysControl?.ItemsSource != null) @@ -305,6 +334,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls shortcutDialog.Opened -= ShortcutDialog_Opened; shortcutDialog.Closing -= ShortcutDialog_Closing; + c.LearnMoreClick -= C_LearnMoreClick; + if (App.GetSettingsWindow() != null) { App.GetSettingsWindow().Activated -= ShortcutDialog_SettingsWindow_Activated; @@ -510,6 +541,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls else { EnableKeys(); + if (lastValidSettings.IsValid()) { if (string.Equals(lastValidSettings.ToString(), hotkeySettings.ToString(), StringComparison.OrdinalIgnoreCase)) @@ -578,16 +610,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { shortcutDialog.IsPrimaryButtonEnabled = true; c.IsError = false; - - // WarningLabel.Style = (Style)App.Current.Resources["SecondaryTextStyle"]; } private void DisableKeys() { shortcutDialog.IsPrimaryButtonEnabled = false; c.IsError = true; - - // WarningLabel.Style = (Style)App.Current.Resources["SecondaryWarningTextStyle"]; } private void Hotkey_KeyUp(int key) @@ -648,6 +676,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls c.Keys = null; c.Keys = HotkeySettings.GetKeysList(); + c.IgnoreConflict = IgnoreConflict; c.HasConflict = hotkeySettings.HasConflict; c.ConflictMessage = hotkeySettings.ConflictDescription; @@ -660,7 +689,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls await shortcutDialog.ShowAsync(); } - private void ShortcutDialog_Reset(ContentDialog sender, ContentDialogButtonClickEventArgs args) + private void C_ResetClick(object sender, RoutedEventArgs e) { hotkeySettings = null; @@ -674,6 +703,20 @@ namespace Microsoft.PowerToys.Settings.UI.Controls GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); } + private void C_ClearClick(object sender, RoutedEventArgs e) + { + hotkeySettings = new HotkeySettings(); + + SetValue(HotkeySettingsProperty, hotkeySettings); + SetKeys(); + + lastValidSettings = hotkeySettings; + shortcutDialog.Hide(); + + // Send RequestAllConflicts IPC to update the UI after changed hotkey settings. + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + private void ShortcutDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) { if (ComboIsValid(lastValidSettings)) @@ -728,7 +771,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls args.Handled = true; if (args.WindowActivationState != WindowActivationState.Deactivated && (hook == null || hook.GetDisposedState() == true)) { - // If the PT settings window gets focussed/activated again, we enable the keyboard hook to catch the keyboard input. + // If the PT settings window gets focused/activated again, we enable the keyboard hook to catch the keyboard input. hook = new HotkeySettingsControlHook(Hotkey_KeyDown, Hotkey_KeyUp, Hotkey_IsActive, FilterAccessibleKeyboardEvents); } else if (args.WindowActivationState == WindowActivationState.Deactivated && hook != null && hook.GetDisposedState() == false) @@ -742,6 +785,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls private void ShortcutDialog_Closing(ContentDialog sender, ContentDialogClosingEventArgs args) { _isActive = false; + lastValidSettings = hotkeySettings; } private void Dispose(bool disposing) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml index b7fec717c7..68ce9ffbff 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml @@ -3,79 +3,332 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tk7controls="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" x:Name="ShortcutContentControl" mc:Ignorable="d"> - + + + + + - + + - + - - - - - - - - - - - - + Margin="0,16,0,0" + Background="{ThemeResource SolidBackgroundFillColorTertiaryBrush}" + CornerRadius="{StaticResource OverlayCornerRadius}"> + + + + + + + + + + + + + + - - - - - - + HorizontalAlignment="Center" + Orientation="Horizontal" + Spacing="12"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs index 8907f12415..9a369f0ebc 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs @@ -2,8 +2,10 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using System.Collections.Generic; - +using System.Diagnostics.Eventing.Reader; +using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -14,8 +16,22 @@ namespace Microsoft.PowerToys.Settings.UI.Controls public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List), typeof(ShortcutDialogContentControl), new PropertyMetadata(default(string))); public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); - public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false, OnConflictPropertyChanged)); public static readonly DependencyProperty ConflictMessageProperty = DependencyProperty.Register("ConflictMessage", typeof(string), typeof(ShortcutDialogContentControl), new PropertyMetadata(string.Empty)); + public static readonly DependencyProperty IgnoreConflictProperty = DependencyProperty.Register("IgnoreConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false, OnIgnoreConflictChanged)); + + public static readonly DependencyProperty ShouldShowConflictProperty = DependencyProperty.Register("ShouldShowConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public static readonly DependencyProperty ShouldShowPotentialConflictProperty = DependencyProperty.Register("ShouldShowPotentialConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + + public event EventHandler IgnoreConflictChanged; + + public event RoutedEventHandler LearnMoreClick; + + public bool IgnoreConflict + { + get => (bool)GetValue(IgnoreConflictProperty); + set => SetValue(IgnoreConflictProperty, value); + } public bool HasConflict { @@ -29,9 +45,22 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set => SetValue(ConflictMessageProperty, value); } + public bool ShouldShowConflict + { + get => (bool)GetValue(ShouldShowConflictProperty); + private set => SetValue(ShouldShowConflictProperty, value); + } + + public bool ShouldShowPotentialConflict + { + get => (bool)GetValue(ShouldShowPotentialConflictProperty); + private set => SetValue(ShouldShowPotentialConflictProperty, value); + } + public ShortcutDialogContentControl() { this.InitializeComponent(); + UpdateShouldShowConflict(); } public List Keys @@ -51,5 +80,54 @@ namespace Microsoft.PowerToys.Settings.UI.Controls get => (bool)GetValue(IsWarningAltGrProperty); set => SetValue(IsWarningAltGrProperty, value); } + + public event RoutedEventHandler ResetClick; + + public event RoutedEventHandler ClearClick; + + private static void OnIgnoreConflictChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as ShortcutDialogContentControl; + if (control == null) + { + return; + } + + control.UpdateShouldShowConflict(); + + control.IgnoreConflictChanged?.Invoke(control, (bool)e.NewValue); + } + + private static void OnConflictPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as ShortcutDialogContentControl; + if (control == null) + { + return; + } + + control.UpdateShouldShowConflict(); + } + + private void UpdateShouldShowConflict() + { + ShouldShowConflict = !IgnoreConflict && HasConflict; + ShouldShowPotentialConflict = IgnoreConflict && HasConflict; + } + + private void ResetBtn_Click(object sender, RoutedEventArgs e) + { + ResetClick?.Invoke(this, new RoutedEventArgs()); + } + + private void ClearBtn_Click(object sender, RoutedEventArgs e) + { + ClearClick?.Invoke(this, new RoutedEventArgs()); + } + + private void LearnMoreBtn_Click(object sender, RoutedEventArgs e) + { + LearnMoreClick?.Invoke(this, new RoutedEventArgs()); + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml index ea3be0bff8..bc46c9d17e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" d:DesignHeight="300" d:DesignWidth="400" mc:Ignorable="d"> @@ -38,11 +38,10 @@ - diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml new file mode 100644 index 0000000000..df058fe220 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs new file mode 100644 index 0000000000..307d499fac --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs @@ -0,0 +1,664 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Shapes; +using Windows.Foundation; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class Timeline : UserControl + { + public TimeSpan StartTime + { + get => (TimeSpan)GetValue(StartTimeProperty); + set => SetValue(StartTimeProperty, value); + } + + public static readonly DependencyProperty StartTimeProperty = DependencyProperty.Register(nameof(StartTime), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: new TimeSpan(22, 0, 0), OnTimeChanged)); + + public TimeSpan EndTime + { + get => (TimeSpan)GetValue(EndTimeProperty); + set => SetValue(EndTimeProperty, value); + } + + public static readonly DependencyProperty EndTimeProperty = DependencyProperty.Register(nameof(EndTime), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: new TimeSpan(7, 0, 0), OnTimeChanged)); + + public TimeSpan? Sunrise + { + get => (TimeSpan?)GetValue(SunriseProperty); + set => SetValue(SunriseProperty, value); + } + + public static readonly DependencyProperty SunriseProperty = DependencyProperty.Register(nameof(Sunrise), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: null, OnTimeChanged)); + + public TimeSpan? Sunset + { + get => (TimeSpan?)GetValue(SunsetProperty); + set => SetValue(SunsetProperty, value); + } + + public static readonly DependencyProperty SunsetProperty = DependencyProperty.Register(nameof(Sunset), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: null, OnTimeChanged)); + + private readonly List _tickHours = new(); + + // Locale 24h/12h + private readonly bool _is24h = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains('H'); + + // Visuals + private readonly List _ticks = new(); + private readonly List _majorTickBottomLabels = new(); // 00,06,12,18,24 (below) + + private readonly List _darkRects = new(); // up to 2 (wrap) + private readonly List _lightRects = new(); // up to 2 (complement) + + private TextBlock _startEdgeLabel; // top-of-chart + private TextBlock _endEdgeLabel; + + private Line _sunriseTick; + private Line _sunsetTick; + + // Add/replace these constants (top of your class) + private const int TickHourStep = 2; // <-- every 2 hours + + private StackPanel _sunrisePanel; // icon + time (below chart) + private StackPanel _sunsetPanel; + + public Timeline() + { + this.InitializeComponent(); + this.Loaded += Timeline_Loaded; + this.IsEnabledChanged += Timeline_IsEnabledChanged; + } + + protected override AutomationPeer OnCreateAutomationPeer() + { + return new TimelineAutomationPeer(this); + } + + private void Timeline_Loaded(object sender, RoutedEventArgs e) + { + CheckEnabledState(); + } + + private void Timeline_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + CheckEnabledState(); + } + + private void CheckEnabledState() + { + if (IsEnabled) + { + this.Opacity = 1.0; + } + else + { + this.Opacity = 0.4; + } + } + + private static void OnTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((Timeline)d).Setup(); + } + + private void Setup() + { + EnsureBands(); + EnsureTicks(); + EnsureStartEndEdgeLabels(); + EnsureSunriseSunsetTicks(); + EnsureSunPanels(); + EnsureMajorTickLabels(); + UpdateAll(); + } + + private void TimelineCanvas_Loaded(object sender, RoutedEventArgs e) + { + // SizeChanged wiring here (as requested) + HeaderCanvas.SizeChanged += (_, __) => UpdateAll(); + TimelineCanvas.SizeChanged += (_, __) => UpdateAll(); + AnnotationCanvas.SizeChanged += (_, __) => UpdateAll(); + Setup(); + } + + private void UpdateAll() + { + UpdateBandsLayout(); + UpdateTicksLayout(); + UpdateStartEndEdgeLabelsLayout(); + UpdateSunriseSunsetTicksLayout(); + UpdateSunPanelsLayout(); + UpdateMajorTickLabelsLayout(); + AutomationProperties.SetHelpText( + this, + $"Start={StartTime};End={EndTime};Sunrise={Sunrise};Sunset={Sunset}"); + } + + // ===== Ticks ===== + private void EnsureTicks() + { + if (_ticks.Count > 0) + { + return; + } + + _tickHours.Clear(); + + // Build ticks at 0,2,4,...,24 but skip the first/last MAJOR ticks (0 and 24) + for (int hour = 0; hour <= 24; hour += TickHourStep) + { + bool isMajor = hour % 6 == 0; + if (isMajor && (hour == 0 || hour == 24)) + { + continue; // skip first/last major ticks + } + + var line = new Line + { + Style = (Style)Application.Current.Resources[isMajor ? "MajorHourTickStyle" : "HourTickStyle"], + }; + + Canvas.SetZIndex(line, 0); // above bands (adjust if needed) + + _ticks.Add(line); + _tickHours.Add(hour); + + // If you actually want these IN the chart, use TimelineCanvas instead: + AnnotationCanvas.Children.Add(line); // or TimelineCanvas.Children.Add(line); + } + } + + private void UpdateTicksLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight; // keeping your offset + if (w <= 0 || h <= 0) + { + return; + } + + double minorLen = h * 0.1; + double majorLen = h * 0.2; + + for (int i = 0; i < _ticks.Count; i++) + { + int hour = _tickHours[i]; + double x = Math.Round((hour / 24.0) * w); + + var line = _ticks[i]; + double len = (hour % 6 == 0) ? majorLen : minorLen; + + line.X1 = x; + line.Y1 = 0; + line.X2 = x; + line.Y2 = len; + } + } + + // ===== Bands (Dark + Light) ===== + private void EnsureBands() + { + if (_darkRects.Count == 0) + { + _darkRects.Add(MakeBandRect(isDark: false)); + _darkRects.Add(MakeBandRect(isDark: false)); + } + + if (_lightRects.Count == 0) + { + _lightRects.Add(MakeBandRect(isDark: true)); + _lightRects.Add(MakeBandRect(isDark: true)); + } + } + + private Border MakeBandRect(bool isDark) + { + var r = new Border(); + if (isDark) + { + r.Style = (Style)Application.Current.Resources["DarkBandStyle"]; + FontIcon icon = new FontIcon(); + icon.Style = (Style)Application.Current.Resources["DarkBandIconStyle"]; + r.Child = icon; + } + else + { + r.Style = (Style)Application.Current.Resources["LightBandStyle"]; + } + + Canvas.SetZIndex(r, 5); // below ticks/labels + TimelineCanvas.Children.Add(r); + return r; + } + + private void UpdateBandsLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight; + if (w <= 0 || h <= 0) + { + return; + } + + foreach (var r in _darkRects) + { + r.Height = h; + Canvas.SetTop(r, 0); + } + + foreach (var r in _lightRects) + { + r.Height = h; + Canvas.SetTop(r, 0); + } + + var darkRanges = ToRanges(StartTime, EndTime); // 1 or 2 segments + var lightRanges = ComplementRanges(darkRanges); // 0..2 + + LayoutRangeRects(_darkRects, darkRanges, w); + LayoutRangeRects(_lightRects, lightRanges, w); + } + + private static void LayoutRangeRects(List rects, List<(TimeSpan Start, TimeSpan End)> ranges, double width) + { + for (int i = 0; i < rects.Count; i++) + { + if (i < ranges.Count) + { + var (start, end) = ranges[i]; + double x = Math.Round((start.TotalHours / 24.0) * width); + double x2 = Math.Round((end.TotalHours / 24.0) * width); + + var r = rects[i]; + Canvas.SetLeft(r, x); + r.Width = Math.Max(0, x2 - x); + r.Visibility = Visibility.Visible; + } + else + { + rects[i].Visibility = Visibility.Collapsed; + } + } + } + + private static List<(TimeSpan Start, TimeSpan End)> ToRanges(TimeSpan start, TimeSpan end) + { + // Full day + if (start == end) + { + return new() { (TimeSpan.Zero, TimeSpan.FromHours(24)) }; + } + + if (start < end) + { + return new() { (start, end) }; + } + + // Wraps midnight + return new() + { + (start, TimeSpan.FromHours(24)), + (TimeSpan.Zero, end), + }; + } + + private static List<(TimeSpan Start, TimeSpan End)> ComplementRanges(List<(TimeSpan Start, TimeSpan End)> dark) + { + var res = new List<(TimeSpan, TimeSpan)>(); + + // If dark covers the full day, there is no light + if (dark.Count == 1 && dark[0].Start == TimeSpan.Zero && dark[0].End == TimeSpan.FromHours(24)) + { + return res; + } + + if (dark.Count == 1) + { + var (ds, de) = dark[0]; + if (ds > TimeSpan.Zero) + { + res.Add((TimeSpan.Zero, ds)); + } + + if (de < TimeSpan.FromHours(24)) + { + res.Add((de, TimeSpan.FromHours(24))); + } + } + else + { + // dark[0] = [a,24), dark[1] = [0,b) => single light [b,a) + var a = dark[0].Start; + var b = dark[1].End; + res.Add((b, a)); + } + + return res; + } + + // ===== Start & End labels (TOP of chart, ABOVE rectangles) ===== + private void EnsureStartEndEdgeLabels() + { + if (_startEdgeLabel == null) + { + _startEdgeLabel = new TextBlock { Style = (Style)Application.Current.Resources["EdgeLabelStyle"] }; + HeaderCanvas.Children.Add(_startEdgeLabel); + Canvas.SetZIndex(_startEdgeLabel, 25); + } + + if (_endEdgeLabel == null) + { + _endEdgeLabel = new TextBlock { Style = (Style)Application.Current.Resources["EdgeLabelStyle"] }; + HeaderCanvas.Children.Add(_endEdgeLabel); + Canvas.SetZIndex(_endEdgeLabel, 25); + } + } + + private void UpdateStartEndEdgeLabelsLayout() + { + double w = TimelineCanvas.ActualWidth; + if (w <= 0) + { + return; + } + + _startEdgeLabel.Text = TimeSpanHelper.Convert(StartTime); + _endEdgeLabel.Text = TimeSpanHelper.Convert(EndTime); + + PlaceTopLabelAtTime(_startEdgeLabel, StartTime, w); + PlaceTopLabelAtTime(_endEdgeLabel, EndTime, w); + } + + private void PlaceTopLabelAtTime(TextBlock tb, TimeSpan t, double timelineWidth) + { + double x = Math.Round((t.TotalHours / 24.0) * timelineWidth); + double textW = MeasureTextWidth(tb); + double desiredLeft = x - (textW / 2.0); + + Canvas.SetLeft(tb, Clamp(desiredLeft, 0, timelineWidth - textW)); + Canvas.SetTop(tb, 0); + tb.Visibility = Visibility.Visible; + } + + // ===== Sunrise/Sunset ticks on chart ===== + private void EnsureSunriseSunsetTicks() + { + if (_sunriseTick == null) + { + _sunriseTick = new Line { Style = (Style)Application.Current.Resources["SunRiseMarkerTickStyle"] }; + TimelineCanvas.Children.Add(_sunriseTick); + Canvas.SetZIndex(_sunriseTick, 12); + } + + if (_sunsetTick == null) + { + _sunsetTick = new Line { Style = (Style)Application.Current.Resources["SunSetMarkerTickStyle"] }; + TimelineCanvas.Children.Add(_sunsetTick); + Canvas.SetZIndex(_sunsetTick, 12); + } + } + + private void UpdateSunriseSunsetTicksLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight + 24; + if (w <= 0 || h <= 0) + { + return; + } + + void Place(Line tick, TimeSpan t) + { + double x = Math.Round((t.TotalHours / 24.0) * w); + tick.X1 = x; + tick.X2 = x; + tick.Y1 = 0; + tick.Y2 = h; + } + + if (_sunriseTick != null) + { + if (Sunrise.HasValue) + { + Place(_sunriseTick, Sunrise.Value); + _sunriseTick.Visibility = Visibility.Visible; + } + else + { + _sunriseTick.Visibility = Visibility.Collapsed; + } + } + + if (_sunsetTick != null) + { + if (Sunset.HasValue) + { + Place(_sunsetTick, Sunset.Value); + _sunsetTick.Visibility = Visibility.Visible; + } + else + { + _sunsetTick.Visibility = Visibility.Collapsed; + } + } + } + + // ===== Sunrise/Sunset panels (below chart) ===== + private void EnsureSunPanels() + { + if (_sunrisePanel == null) + { + _sunrisePanel = MakeSunPanel("\uEC8A"); + AnnotationCanvas.Children.Add(_sunrisePanel); + } + + if (_sunsetPanel == null) + { + _sunsetPanel = MakeSunPanel("\uED3A"); + AnnotationCanvas.Children.Add(_sunsetPanel); + } + } + + private StackPanel MakeSunPanel(string iconEmoji) + { + var icon = new FontIcon { Glyph = iconEmoji, Style = (Style)Application.Current.Resources["SunIconStyle"] }; + var sp = new StackPanel { Orientation = Orientation.Vertical, Spacing = 2 }; + sp.Children.Add(icon); + return sp; + } + + private void UpdateSunPanelsLayout() + { + double timelineW = TimelineCanvas.ActualWidth; + double annotationW = AnnotationCanvas.ActualWidth; + if (annotationW <= 0) + { + annotationW = timelineW; + } + + if (timelineW <= 0 || annotationW <= 0) + { + return; + } + + void Place(StackPanel sp, TimeSpan t) + { + double panelW = MeasureElementWidth(sp); + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double left = Clamp(xTimeline - (panelW / 2.0), 0, annotationW - panelW); + Canvas.SetLeft(sp, left); + Canvas.SetTop(sp, 8); + } + + if (_sunrisePanel != null) + { + if (Sunrise.HasValue) + { + ToolTipService.SetToolTip(_sunrisePanel, $"Sunrise: {TimeSpanHelper.Convert(Sunrise.Value)}"); + _sunrisePanel.Visibility = Visibility.Visible; + Place(_sunrisePanel, Sunrise.Value); + } + else + { + ToolTipService.SetToolTip(_sunrisePanel, null); + _sunrisePanel.Visibility = Visibility.Collapsed; + } + } + + if (_sunsetPanel != null) + { + if (Sunset.HasValue) + { + ToolTipService.SetToolTip(_sunsetPanel, $"Sunset: {TimeSpanHelper.Convert(Sunset.Value)}"); + _sunsetPanel.Visibility = Visibility.Visible; + Place(_sunsetPanel, Sunset.Value); + } + else + { + ToolTipService.SetToolTip(_sunsetPanel, null); + _sunsetPanel.Visibility = Visibility.Collapsed; + } + } + } + + // ===== Major labels BELOW chart (00,06,12,18,24) ===== + private void EnsureMajorTickLabels() + { + if (_majorTickBottomLabels.Count > 0) + { + return; + } + + // Includes 24:00 at end + for (int i = 0; i < 5; i++) + { + var tb = new TextBlock { Style = (Style)Application.Current.Resources["MajorTickLabelStyle"] }; + Canvas.SetZIndex(tb, 5); // on annotation canvas + _majorTickBottomLabels.Add(tb); + AnnotationCanvas.Children.Add(tb); + } + } + + private void UpdateMajorTickLabelsLayout() + { + double timelineW = TimelineCanvas.ActualWidth; + double annotationW = AnnotationCanvas.ActualWidth; + if (annotationW <= 0) + { + annotationW = timelineW; + } + + if (timelineW <= 0 || annotationW <= 0) + { + return; + } + + int[] hours = { 0, 6, 12, 18, 24 }; + + // 1) Place labels first + for (int i = 0; i < hours.Length; i++) + { + var tb = _majorTickBottomLabels[i]; + var t = TimeSpan.FromHours(hours[i]); + tb.Text = TimeSpanHelper.Convert(t); + + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double textW = MeasureTextWidth(tb); + double left = xTimeline - (textW / 2.0); + + // Middle ones (06, 12) exact center; edges clamp inside canvas + if (i == 1 || i == 2) + { + Canvas.SetLeft(tb, left); + } + else + { + Canvas.SetLeft(tb, Clamp(left, 0, annotationW - textW)); + } + + Canvas.SetTop(tb, 8); // your existing baseline below chart + tb.Visibility = Visibility.Visible; + } + + // 2) Compute sunrise/sunset occupied horizontal ranges (if present) + (double Left, double Right)? sunriseBounds = null; + (double Left, double Right)? sunsetBounds = null; + + if (Sunrise.HasValue && _sunrisePanel != null) + { + sunriseBounds = GetAnnotationBoundsForTime(Sunrise.Value, timelineW, annotationW, _sunrisePanel); + } + + if (Sunset.HasValue && _sunsetPanel != null) + { + sunsetBounds = GetAnnotationBoundsForTime(Sunset.Value, timelineW, annotationW, _sunsetPanel); + } + + // 3) Hide any label that intersects the sunrise/sunset panel bounds + for (int i = 0; i < hours.Length; i++) + { + var tb = _majorTickBottomLabels[i]; + if (tb.Visibility != Visibility.Visible) + { + continue; + } + + var lbl = GetLabelBounds(tb); + + bool hide = + (sunriseBounds.HasValue && Intersects(lbl, sunriseBounds.Value)) || + (sunsetBounds.HasValue && Intersects(lbl, sunsetBounds.Value)); // include sunset too; remove if you only want sunrise + + tb.Visibility = hide ? Visibility.Collapsed : Visibility.Visible; + } + } + + // ===== Utilities ===== + private static double Clamp(double v, double min, double max) => Math.Max(min, Math.Min(max, v)); + + private static double MeasureElementWidth(FrameworkElement el) + { + el.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return el.DesiredSize.Width; + } + + private static double MeasureTextWidth(TextBlock tb) + { + tb.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return tb.DesiredSize.Width; + } + + private static bool Intersects((double Left, double Right) a, (double Left, double Right) b, double pad = 4) + { + // Horizontal overlap with padding + return !(a.Right + pad <= b.Left || b.Right + pad <= a.Left); + } + + private (double Left, double Right) GetAnnotationBoundsForTime(TimeSpan t, double timelineW, double annotationW, FrameworkElement element) + { + // Compute the *actual* left/right the panel will occupy in AnnotationCanvas + double panelW = MeasureElementWidth(element); + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double left = Clamp(xTimeline - (panelW / 2.0), 0, annotationW - panelW); + return (left, left + panelW); + } + + private (double Left, double Right) GetLabelBounds(TextBlock tb) + { + double w = MeasureTextWidth(tb); + double left = Canvas.GetLeft(tb); + return (left, left + w); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineAutomationPeer.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineAutomationPeer.cs new file mode 100644 index 0000000000..a32f99059a --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineAutomationPeer.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public partial class TimelineAutomationPeer : FrameworkElementAutomationPeer + { + public TimelineAutomationPeer(Timeline owner) + : base(owner) + { + } + + protected override string GetClassNameCore() => "Timeline"; + + protected override AutomationControlType GetAutomationControlTypeCore() + => AutomationControlType.Custom; + + protected override string GetAutomationIdCore() + { + var owner = (Timeline)Owner; + var id = AutomationProperties.GetAutomationId(owner); + return string.IsNullOrEmpty(id) ? base.GetAutomationIdCore() : id; + } + + protected override string GetNameCore() + { + var owner = (Timeline)Owner; + var name = AutomationProperties.GetName(owner); + return !string.IsNullOrEmpty(name) + ? name + : $"Timeline from {owner.StartTime} to {owner.EndTime}"; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml new file mode 100644 index 0000000000..82acf66ef5 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml index 07b6a00c21..8dea006b08 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml @@ -111,7 +111,8 @@ x:Uid="UpdateAvailableInfoBar" IsClosable="False" IsOpen="{x:Bind ViewModel.IsUpdateAvailable, Mode=OneWay}" - Severity="Success" /> + Severity="Success" + Tapped="UpdateInfoBar_Tapped" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs index aad7dcf215..51219309e0 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs @@ -10,6 +10,7 @@ using Microsoft.PowerToys.Settings.UI.Controls; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -183,5 +184,14 @@ namespace Microsoft.PowerToys.Settings.UI.Flyout // Closing manually the flyout since no window will steal the focus App.GetFlyoutWindow()?.Hide(); } + + private void UpdateInfoBar_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + { + // Hide the flyout before opening settings window + App.GetFlyoutWindow()?.Hide(); + + // Open Settings window directly to General page where update controls are located + App.OpenSettingsWindow(typeof(GeneralPage)); + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml index e029aa41f6..191fae9d1d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> @@ -17,7 +17,7 @@ - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + DataContext="{x:Bind ViewModel.AdditionalActions.ImageToText, Mode=OneWay}" + HeaderIcon="{ui:FontIcon Glyph=}"> + + + + + + + + + + + + - - - - - - - - - - - - - - - - | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + 900 + 700 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs index a06e5838a4..0d7273f924 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs @@ -66,5 +66,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views App.GetOobeWindow().Activate(); } + + private void SortAlphabetical_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.Alphabetical; + } + + private void SortByStatus_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.ByStatus; + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml index b8b1f54e56..5cabb81f61 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml @@ -13,23 +13,14 @@ - - - - - - - - + + + + + - - - - - - - - - + + + + + - - - - - - - - - + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml index e06d367941..cf67586a3e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml @@ -48,7 +48,6 @@ FontWeight="SemiBold" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs index 72de0843d1..19375d90f7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs @@ -18,6 +18,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views InitializeComponent(); var settingsUtils = new SettingsUtils(); ViewModel = new HostsViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); + BackupsCountInputSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Description"); + BackupsCountInputAgeSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputAgeSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Age_Description"); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml index a8fc6b1e8a..8620acbd9d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml @@ -9,7 +9,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" - xmlns:toolkitconverters="using:CommunityToolkit.WinUI.UI.Converters" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" x:Name="RootPage" AutomationProperties.LandmarkType="Main" @@ -23,7 +23,7 @@ - @@ -31,25 +31,14 @@ - - - - - - - - - - + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs new file mode 100644 index 0000000000..974447a20e --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs @@ -0,0 +1,379 @@ +// 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.Globalization; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using PowerToys.GPOWrapper; +using Settings.UI.Library; +using Windows.Devices.Geolocation; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + public sealed partial class LightSwitchPage : NavigablePage, IRefreshablePage + { + private readonly string appName = "LightSwitch"; + private readonly SettingsUtils settingsUtils; + private readonly Func sendConfigMsg = ShellPage.SendDefaultIPCMessage; + + private readonly SettingsRepository generalSettingsRepository; + private readonly SettingsRepository moduleSettingsRepository; + + private readonly IFileSystem fileSystem; + private readonly IFileSystemWatcher fileSystemWatcher; + private readonly DispatcherQueue dispatcherQueue; + private bool suppressViewModelUpdates; + + private LightSwitchViewModel ViewModel { get; set; } + + public LightSwitchPage() + { + this.settingsUtils = new SettingsUtils(); + this.sendConfigMsg = ShellPage.SendDefaultIPCMessage; + + this.generalSettingsRepository = SettingsRepository.GetInstance(this.settingsUtils); + this.moduleSettingsRepository = SettingsRepository.GetInstance(this.settingsUtils); + + // Get settings from JSON (or defaults if JSON missing) + var darkSettings = this.moduleSettingsRepository.SettingsConfig; + + // Pass them into the ViewModel + this.ViewModel = new LightSwitchViewModel(darkSettings, this.sendConfigMsg); + this.ViewModel.PropertyChanged += ViewModel_PropertyChanged; + + this.LoadSettings(this.generalSettingsRepository, this.moduleSettingsRepository); + + DataContext = this.ViewModel; + + var settingsPath = this.settingsUtils.GetSettingsFilePath(this.appName); + + this.dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + this.fileSystem = new FileSystem(); + + this.fileSystemWatcher = this.fileSystem.FileSystemWatcher.New(); + this.fileSystemWatcher.Path = this.fileSystem.Path.GetDirectoryName(settingsPath); + this.fileSystemWatcher.Filter = this.fileSystem.Path.GetFileName(settingsPath); + this.fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime; + this.fileSystemWatcher.Changed += Settings_Changed; + this.fileSystemWatcher.EnableRaisingEvents = true; + + this.InitializeComponent(); + Loaded += LightSwitchPage_Loaded; + Loaded += (s, e) => this.ViewModel.OnPageLoaded(); + } + + public void RefreshEnabledState() + { + this.ViewModel.RefreshEnabledState(); + } + + private void LightSwitchPage_Loaded(object sender, RoutedEventArgs e) + { + if (this.ViewModel.SearchLocations.Count == 0) + { + foreach (var city in SearchLocationLoader.GetAll()) + { + this.ViewModel.SearchLocations.Add(city); + } + } + + this.ViewModel.InitializeScheduleMode(); + } + + private async void GetGeoLocation_Click(object sender, RoutedEventArgs e) + { + this.LatitudeBox.IsEnabled = false; + this.LongitudeBox.IsEnabled = false; + this.SyncButton.IsEnabled = false; + this.SyncLoader.IsActive = true; + this.SyncLoader.Visibility = Visibility.Visible; + this.LocationResultPanel.Visibility = Visibility.Collapsed; + + try + { + // Request access + var accessStatus = await Geolocator.RequestAccessAsync(); + if (accessStatus != GeolocationAccessStatus.Allowed) + { + // User denied location or it's not available + return; + } + + var geolocator = new Geolocator { DesiredAccuracy = PositionAccuracy.Default }; + + Geoposition pos = await geolocator.GetGeopositionAsync(); + + double latitude = Math.Round(pos.Coordinate.Point.Position.Latitude); + double longitude = Math.Round(pos.Coordinate.Point.Position.Longitude); + + ViewModel.LocationPanelLatitude = latitude; + ViewModel.LocationPanelLongitude = longitude; + + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LocationPanelLightTimeMinutes = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.LocationPanelDarkTimeMinutes = (result.SunsetHour * 60) + result.SunsetMinute; + + // Since we use this mode, we can remove the selected city data. + this.ViewModel.SelectedCity = null; + + // ViewModel.CityTimesText = $"Sunrise: {result.SunriseHour}:{result.SunriseMinute:D2}\n" + $"Sunset: {result.SunsetHour}:{result.SunsetMinute:D2}"; + this.SyncButton.IsEnabled = true; + this.SyncLoader.IsActive = false; + this.SyncLoader.Visibility = Visibility.Collapsed; + this.LocationDialog.IsPrimaryButtonEnabled = true; + this.LatitudeBox.IsEnabled = true; + this.LongitudeBox.IsEnabled = true; + this.LocationResultPanel.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + this.SyncButton.IsEnabled = true; + this.SyncLoader.IsActive = false; + this.SyncLoader.Visibility = Visibility.Collapsed; + this.LocationResultPanel.Visibility = Visibility.Collapsed; + this.LatitudeBox.IsEnabled = true; + this.LongitudeBox.IsEnabled = true; + Logger.LogInfo($"Location error: " + ex.Message); + } + } + + private void LatLonBox_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) + { + double latitude = this.LatitudeBox.Value; + double longitude = this.LongitudeBox.Value; + + if (double.IsNaN(latitude) || double.IsNaN(longitude) || (latitude == 0 && longitude == 0)) + { + return; + } + + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LocationPanelLightTimeMinutes = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.LocationPanelDarkTimeMinutes = (result.SunsetHour * 60) + result.SunsetMinute; + + this.LocationResultPanel.Visibility = Visibility.Visible; + if (this.LocationDialog != null) + { + this.LocationDialog.IsPrimaryButtonEnabled = true; + } + } + + private void LocationDialog_PrimaryButtonClick(object sender, ContentDialogButtonClickEventArgs args) + { + if (double.IsNaN(this.LatitudeBox.Value) || double.IsNaN(this.LongitudeBox.Value)) + { + return; + } + + double latitude = this.LatitudeBox.Value; + double longitude = this.LongitudeBox.Value; + + // need to save the values + this.ViewModel.Latitude = latitude.ToString(CultureInfo.InvariantCulture); + this.ViewModel.Longitude = longitude.ToString(CultureInfo.InvariantCulture); + this.ViewModel.SyncButtonInformation = $"{this.ViewModel.Latitude}, {this.ViewModel.Longitude}"; + + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LightTime = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.DarkTime = (result.SunsetHour * 60) + result.SunsetMinute; + + this.SunriseModeChartState(); + } + + private void ViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (this.suppressViewModelUpdates) + { + return; + } + + if (e.PropertyName == "IsEnabled") + { + if (this.ViewModel.IsEnabled != this.generalSettingsRepository.SettingsConfig.Enabled.LightSwitch) + { + this.generalSettingsRepository.SettingsConfig.Enabled.LightSwitch = this.ViewModel.IsEnabled; + + var generalSettingsMessage = new OutGoingGeneralSettings(this.generalSettingsRepository.SettingsConfig).ToString(); + Logger.LogInfo($"Saved general settings from Light Switch page."); + + this.sendConfigMsg?.Invoke(generalSettingsMessage); + } + } + else + { + if (this.ViewModel.ModuleSettings != null) + { + SndLightSwitchSettings currentSettings = new(this.moduleSettingsRepository.SettingsConfig); + SndModuleSettings csIpcMessage = new(currentSettings); + + SndLightSwitchSettings outSettings = new(this.ViewModel.ModuleSettings); + SndModuleSettings outIpcMessage = new(outSettings); + + string csMessage = csIpcMessage.ToJsonString(); + string outMessage = outIpcMessage.ToJsonString(); + + if (!csMessage.Equals(outMessage, StringComparison.Ordinal)) + { + Logger.LogInfo($"Saved Light Switch settings from Light Switch page."); + + this.sendConfigMsg?.Invoke(outMessage); + } + } + } + } + + private void LoadSettings(SettingsRepository generalSettingsRepository, SettingsRepository moduleSettingsRepository) + { + if (generalSettingsRepository != null) + { + if (moduleSettingsRepository != null) + { + UpdateViewModelSettings(moduleSettingsRepository.SettingsConfig, generalSettingsRepository.SettingsConfig); + } + else + { + throw new ArgumentNullException(nameof(moduleSettingsRepository)); + } + } + else + { + throw new ArgumentNullException(nameof(generalSettingsRepository)); + } + } + + private void UpdateViewModelSettings(LightSwitchSettings lightSwitchSettings, GeneralSettings generalSettings) + { + if (lightSwitchSettings != null) + { + if (generalSettings != null) + { + this.ViewModel.IsEnabled = generalSettings.Enabled.LightSwitch; + this.ViewModel.ModuleSettings = (LightSwitchSettings)lightSwitchSettings.Clone(); + + UpdateEnabledState(generalSettings.Enabled.LightSwitch); + } + else + { + throw new ArgumentNullException(nameof(generalSettings)); + } + } + else + { + throw new ArgumentNullException(nameof(lightSwitchSettings)); + } + } + + private void Settings_Changed(object sender, FileSystemEventArgs e) + { + this.dispatcherQueue.TryEnqueue(() => + { + this.suppressViewModelUpdates = true; + + this.moduleSettingsRepository.ReloadSettings(); + this.LoadSettings(this.generalSettingsRepository, this.moduleSettingsRepository); + + this.suppressViewModelUpdates = false; + }); + } + + private void UpdateEnabledState(bool recommendedState) + { + var enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredLightSwitchEnabledValue(); + + if (enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + this.ViewModel.IsEnabledGpoConfigured = true; + this.ViewModel.EnabledGPOConfiguration = enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + } + else + { + this.ViewModel.IsEnabled = recommendedState; + } + } + + private async void SyncLocationButton_Click(object sender, RoutedEventArgs e) + { + this.LocationDialog.IsPrimaryButtonEnabled = false; + this.LocationResultPanel.Visibility = Visibility.Collapsed; + await this.LocationDialog.ShowAsync(); + } + + private void CityAutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput && !string.IsNullOrWhiteSpace(sender.Text)) + { + string query = sender.Text.ToLower(CultureInfo.CurrentCulture); + + // Filter your cities (assuming ViewModel.Cities is a List) + var filtered = this.ViewModel.SearchLocations + .Where(c => + (c.City?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false) || + (c.Country?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false)) + .ToList(); + + sender.ItemsSource = filtered; + } + } + + /* private void CityAutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + if (args.SelectedItem is SearchLocation location) + { + ViewModel.SelectedCity = location; + + // CityAutoSuggestBox.Text = $"{location.City}, {location.Country}"; + LocationDialog.IsPrimaryButtonEnabled = true; + LocationResultPanel.Visibility = Visibility.Visible; + } + } */ + + private void ModeSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + switch (this.ViewModel.ScheduleMode) + { + case "FixedHours": + VisualStateManager.GoToState(this, "ManualState", true); + this.TimelineCard.Visibility = Visibility.Visible; + break; + case "SunsetToSunrise": + VisualStateManager.GoToState(this, "SunsetToSunriseState", true); + this.SunriseModeChartState(); + break; + default: + VisualStateManager.GoToState(this, "OffState", true); + this.TimelineCard.Visibility = Visibility.Collapsed; + break; + } + } + + private void SunriseModeChartState() + { + if (this.ViewModel.Latitude != "0.0" && this.ViewModel.Longitude != "0.0") + { + this.TimelineCard.Visibility = Visibility.Visible; + this.LocationWarningBar.Visibility = Visibility.Collapsed; + } + else + { + this.TimelineCard.Visibility = Visibility.Collapsed; + this.LocationWarningBar.Visibility = Visibility.Visible; + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml index 1c90671597..221cb8e9a6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml @@ -14,26 +14,18 @@ - - - - - - - - + + + + + + - - - - - - - - - + + + + + + @@ -117,26 +112,22 @@ - - - + - + - + @@ -153,6 +145,7 @@ - + @@ -195,27 +189,22 @@ - - - - - - - - - + + + + + + @@ -229,17 +218,18 @@ - + - + - + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -369,6 +382,14 @@ Value="{x:Bind ViewModel.MousePointerCrosshairsBorderSize, Mode=TwoWay}" /> + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs index e07795ac6a..ac4c7cc71d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs @@ -42,6 +42,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), + SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml index cc47dd5808..63afc29fbf 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml @@ -3,17 +3,17 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" - xmlns:converters="using:CommunityToolkit.WinUI.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - - + @@ -21,27 +21,18 @@ - - - - - - - - - + + + + + + - - - - @@ -197,34 +184,28 @@ x:Name="ServiceSettingsGroup" x:Uid="MouseWithoutBorders_ServiceSettings" IsEnabled="{x:Bind ViewModel.CanToggleUseService, Mode=OneWay}"> - - - - - - - - - - + + + + + + + + + - - - - - - - - + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml index 2a3c57ede5..d837f35c3f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml @@ -18,24 +18,17 @@ ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical" Spacing="2"> - - - - - - - - - + + + + + - - - - - - - - - - - + + + + + - + + @@ -93,116 +82,114 @@ - - - - - - - - + + + + + + - - - + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml index f988f8917d..49da343744 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml @@ -14,29 +14,27 @@ - - - - - - - - + + + + + + + + + + + + HeaderIcon="{ui:FontIcon Glyph=}" + Visibility="{x:Bind ViewModel.EnableSpaceToActivate, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml index b29c282527..d8d76463a5 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml @@ -27,25 +27,14 @@ ModuleImageSource="ms-appx:///Assets/Settings/Modules/QuickAccent.png"> - - - - - - - - - - + + + + + - - - - - - - - - - + + + + + - - + - + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml index 42d59945af..aae80d05e7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml @@ -94,6 +94,8 @@ 516 @@ -202,6 +204,12 @@ helpers:NavHelper.NavigateTo="views:ColorPickerPage" AutomationProperties.AutomationId="ColorPickerNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ColorPicker.png}" /> + + + + + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseUtils.png}"> + + + + - - - - - - - - + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml index 8aeaa29221..97caf4a331 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml @@ -16,32 +16,21 @@ ModuleImageSource="ms-appx:///Assets/Settings/Modules/Workspaces.png"> - - - - - - - - + + + + + - - - - - - - - - - + + + + + + @@ -62,6 +54,9 @@ + + + 1.0 + + + GIF + MP4 + + diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index d02ecafc21..478494aeed 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -626,17 +626,99 @@ opera.exe Enable Paste with AI + + ## Preview Terms + +Please review the placeholder content that represents the final terms and usage guidance for Advanced Paste. + +1. This is sample information. +2. Real policy content will be provided later. +3. Continue only if you are comfortable with the above. + + + I have read and accept the information above. + + + Enable OpenAI content moderation + + + Enable OpenAI content moderation + + + Use built-in functions to handle complex tasks. Token consumption may increase. + - Clipboard history + Access Clipboard History - Save multiple items to your clipboard. This is an OS feature. + View and select previously copied items Actions - Additional actions + Custom actions + + + You're running local models directly on your device. Their behavior may vary or be unpredictable. + + + Note: After installing the Foundry Local CLI, restart PowerToys to use it. + Message informing users that PowerToys needs to be restarted after installing Foundry Local CLI + + + You're running local models directly on your device. Their behavior may vary or be unpredictable. + + + Your API key connects directly to OpenAI services. By setting up this provider, you agree to comply with OpenAI's usage policies and data handling practices. + + + Terms of Use + + + Privacy Policy + + + Your API key connects directly to Microsoft Azure services. By setting up this provider, you agree to comply with Microsoft Azure's usage policies and data handling practices. + + + Microsoft Azure Terms of Service + + + Microsoft Privacy Statement + + + Your API key connects directly to Microsoft Azure services. By setting up this provider, you agree to comply with Microsoft Azure's usage policies and data handling practices. + + + Microsoft Azure Terms of Service + + + Microsoft Privacy Statement + + + Your API key connects directly to Google services. By setting up this provider, you agree to comply with Google's usage policies and data handling practices. + + + Google Terms of Service + + + Google Privacy Policy + + + Your API key connects directly to Mistral services. By setting up this provider, you agree to comply with Mistral's usage policies and data handling practices. + + + Mistral Terms of Use + + + Mistral Privacy Policy + + + Ollama Terms of Service + + + Ollama Privacy Policy Current key remappings @@ -1956,6 +2038,9 @@ Made with 💗 by Microsoft and the PowerToys community. Name + + Description + Prompt @@ -2163,7 +2248,7 @@ Take a moment to preview the various utilities listed or view our comprehensive Press the **Restart as administrator** button from the File Locksmith UI to also get information on elevated processes that might be using the files. - Select **View** which is located at the top of File Explorer, followed by **Show**, and then **Preview pane**. + Select **View** which is located at the top of File Explorer, followed by **Show**, and then **Preview pane**. From there, simply click on one of the supported files in the File Explorer and observe the content on the preview pane! @@ -2590,6 +2675,30 @@ From there, simply click on one of the supported files in the File Explorer and Use a keyboard shortcut to highlight left and right mouse clicks. Mouse as in the hardware peripheral. + + Enable CursorWrap + + + CursorWrap + + + Wrap the mouse cursor between monitor edges + + + Activation and behavior + + + Set shortcut + + + Disable wrapping while dragging + + + Auto-activate on startup + + + Automatically activate on utility startup + Mouse Pointer Crosshairs Mouse as in the hardware peripheral. @@ -2667,23 +2776,20 @@ From there, simply click on one of the supported files in the File Explorer and Press a combination of keys to change this shortcut. Right-click to remove the key combination, thereby deactivating the shortcut. - - Reset - Save Activation shortcut - + Invalid shortcut - Only shortcuts that start with **Windows key**, **Ctrl**, **Alt** or **Shift** are valid. + A shortcut should start with **Windows key**, **Ctrl**, **Alt** or **Shift**. The ** sequences are used for text formatting of the key names. Don't remove them on translation. - + Possible shortcut interference with Alt Gr Alt Gr refers to the right alt key on some international keyboards @@ -2691,8 +2797,8 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Shortcuts with **Ctrl** and **Alt** may remove functionality from some international keyboards, because **Ctrl** + **Alt** = **Alt Gr** in those keyboards. The ** sequences are used for text formatting of the key names. Don't remove them on translation. - - Shortcut conflict + + This shortcut has a potential conflict, but the warning is ignored. A conflict has been detected for this shortcut. @@ -2721,7 +2827,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Refers to the utility name - Find My Mouse highlights the position of the cursor when pressing the Ctrl key twice, using a custom shortcut or when shaking the mouse. + Highlight the position of the cursor when pressing the Ctrl key twice, using a custom shortcut or when shaking the mouse. "Ctrl" is a keyboard key. "Find My Mouse" is the name of the utility @@ -2810,7 +2916,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Refers to the utility name - Mouse Highlighter mode will highlight mouse clicks. + Highlight mouse clicks. "Mouse Highlighter" is the name of the utility. Mouse is the hardware mouse. @@ -2855,7 +2961,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Refers to the utility name - Mouse Pointer Crosshairs draws crosshairs centered on the mouse pointer. + Draw crosshairs centered on the mouse pointer. "Mouse Pointer Crosshairs" is the name of the utility. Mouse is the hardware mouse. @@ -2896,6 +3002,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Crosshairs fixed length (px) px = pixels + + Crosshairs orientation + + + Vertical and horizontal lines + + + Vertical only + + + Horizontal only + Gliding cursor @@ -3152,6 +3270,19 @@ Right-click to remove the key combination, thereby deactivating the shortcut. You'll be asked to confirm before files are moved to the Recycle Bin + + Activation method + + + Use a shortcut or press the Spacebar when a file is selected + Spacebar is a physical keyboard key + + + Custom shortcut + + + Spacebar + Disable rounded corners when a window is snapped @@ -3239,6 +3370,15 @@ Activate by holding the key for the character you want to add an accent to, then Pin a window + + Theme toggle shortcut + + + Switch between light and dark mode + + + Toggle theme + Pick a color @@ -3270,7 +3410,7 @@ Activate by holding the key for the character you want to add an accent to, then An AI powered tool to put your clipboard content into any format you need, focused towards developer workflows. - This feature allows you to format your clipboard content with the power of AI. An OpenAI API key is required. + Transform your clipboard content with the power of AI. An cloud or local endpoint is required. Learn more @@ -3904,7 +4044,7 @@ Activate by holding the key for the character you want to add an accent to, then Paste with AI - Behavior + Activation & behavior Custom format preview @@ -3912,11 +4052,8 @@ Activate by holding the key for the character you want to add an accent to, then Preview the output of AI formats and Image to text before pasting - - Enable advanced AI - - - Add advanced capabilities when using 'Paste with AI' including the power to 'chain' multiple transformations together and work with images and files. This feature may consume more API credits when used. + + Enable Advanced AI Advanced Paste is a tool to put your clipboard content into any format you need, focused towards developer workflows. It can paste as plain text, markdown, or json directly with the UX or with a direct keystroke invoke. These are fully locally executed. In addition, it has an AI powered option that is 100% opt-in and requires an Open AI key. Note: this will replace the formatted text in your clipboard with the selected format. @@ -4325,6 +4462,18 @@ Activate by holding the key for the character you want to add an accent to, then Home + + Sort utilities + + + Alphabetically + + + By status + + + Sort utilities + Preview @@ -4445,9 +4594,13 @@ Activate by holding the key for the character you want to add an accent to, then If you do not have credits you will see an 'API key quota exceeded' error - - Automatically close the AdvancedPaste window after it loses focus - AdvancedPaste is a product name, do not loc + + Automatically close the window after it loses focus + Advanced Paste is a product name, do not loc + + + Show clipboard preview + Enables display of clipboard contents preview in the Advanced Paste window The Command Not Found module is disabled by your organization. @@ -4631,6 +4784,9 @@ Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or s Animate zoom in and zoom out + + Smooth zoomed image + Specify the initial level of magnification when zooming in @@ -4859,6 +5015,9 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Scaling + + Format + Capture audio input @@ -5000,25 +5159,12 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m System Tools - - Command Palette - - - A better quick launcher - - - Open Command Palette - Enable Command Palette - "Command Palette" is the name of the utility. - - - A fully extensible quick launcher with a richer display and additional capabilities without sacrificing performance. + Command Palette is a product name, do not loc - Learn more about Command Palette - Command Palette is a product name, do not loc + Learn more Command Palette @@ -5026,11 +5172,11 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m A fully extensible quick launcher with a richer display and additional capabilities without sacrificing performance. - "Command Palette" is a product name + Command Palette is a product name, do not loc Command Palette - "Command Palette" is a product name + Command Palette is a product name, do not loc and start typing! @@ -5063,14 +5209,8 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Retry - - Activation - - - Activation shortcut - - - Open Command Palette settings to customize the activation shortcut + + Settings chroma (CIE LCh) @@ -5139,7 +5279,7 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Configure shortcut - Configure shortcut + Assign shortcut Quick access @@ -5202,6 +5342,129 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Stores diagnostic data locally in .xml format; folder may include .etl files as well. May use up 1 GB or more of disk space. + + Light Switch + + + Enable Light Switch + + + Easily switch between light and dark mode - on a schedule, automatically, or with a shortcut. + + + Light Switch + + + Learn more about Light Switch + + + Behavior + + + Enable Light Switch + + + Shortcuts + + + Schedule + + + Mode + + + Determine when dark mode should be turned on + + + Off + + + Fixed hours + + + Sunset to sunrise + + + Scheduling is turned off. + + + Turn on dark mode + + + Turn off dark mode + + + Location + + + Used to automatically calculate accurate sunrise and sunset times + + + Offset (in minutes) + + + Adjust the trigger time by starting earlier or later + + + Location required + + + Sync your location so Light Switch can calculate the correct sunrise- and sunset times + + + Apply dark mode to + + + Pick which parts of your PC should follow Light Switch + + + System + + + Taskbar, Start, and other system UI + + + Apps + + + Supported applications + + + Select a location + + + Save + + + Cancel + + + Detect your location automatically or enter it manually to calculate sunrise and sunset times. + + + Sunrise + + + Sunset + + + Latitude + + + Longitude + + + Detect location + + + Detect location + + + Sunrise + + + Sunset + Close PowerToys Don't loc "PowerToys" @@ -5243,23 +5506,23 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m PowerToys shortcut conflicts - + PowerToys shortcut conflicts - Conflicting shortcuts may cause unexpected behavior. Edit them here or go to the module settings to update them. + If any shortcut conflicts are detected, they’ll appear below. Conflicts can happen between PowerToys utilities or Windows system shortcuts, and may cause unexpected behavior. If everything works as expected, you can safely ignore the conflict. Conflicts found for - System + System shortcut - Windows system shortcut + This shortcut is reserved by Windows and can't be reassigned. - - This shortcut can't be changed. + + See all Windows shortcuts This shortcut is used by Windows and can't be changed. @@ -5276,6 +5539,9 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Shortcut conflicts + + Shortcut conflicts + No conflicts found @@ -5299,4 +5565,220 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Utilities + + Light Switch + Product name. Do not localize this string + + + Light Switch automatically manages your Windows light and dark mode based on schedules, sunrise/sunset times, or manual control. Keep your system theme synchronized with your preferences and daily rhythm. + Light Switch is a product name, do not localize + + + Open **PowerToys Settings** and enable Light Switch to set up automatic theme switching + Light Switch is a product name, do not localize + + + Use the **keyboard shortcut** to instantly toggle between light and dark modes, or set up **sunrise/sunset automation** for natural theme transitions. + Light Switch is a product name, do not localize + + + Dismiss + + + Dismiss + + + Reset shortcut + + + Reset to the default shortcut + + + Reset + + + Clear shortcut + + + Clear and unassign this shortcut + + + Clear + + + Learn more + + + Backup + + + Backup hosts file + "Hosts" refers to the system hosts file, do not loc + + + Automatically create a backup of the hosts file when you save for the first time in a session + "Hosts" refers to the system hosts file, do not loc + + + Location + + + Select location + + + Automatically delete backups + + + Days + + + Set the number of backups to keep. Older backups will be deleted once the limit is reached. + + + Set the number of days to keep backups. Older backups will be deleted once the limit is reached. + + + Never + + + Based on count + + + Based on age and count + + + Set an optional number of backups to always keep despite their age + + + Backup count + + + Set Location + + + Open Foundry Local model list + Do not localize "Foundry Local", it's a product name + + + Run Foundry Local to download or add a local model + Do not localize "Foundry Local", it's a product name + + + No models downloaded + + + Loading Foundry Local status.. + Do not localize "Foundry Local", it's a product name + + + Foundry Local model + Do not localize "Foundry Local", it's a product name + + + Use the Foundry Local CLI to download models that run locally on-device. They'll appear here. + Do not localize "Foundry Local", it's a product name + + + Refresh model list + + + Foundry Local is not available on this device yet. + Do not localize "Foundry Local", it's a product name + + + Start the Foundry Local service before returning to PowerToys. + + + Follow the Foundry Local CLI guide + Do not localize "Foundry Local", it's a product name + + + Model providers + + + Add online or local models + + + Edit + + + Remove + + + Model name + + + Endpoint URL + + + API key + + + Enter API key + + + API version + + + Deployment name + + + System prompt + + + Save + + + Cancel + + + Display a preview of the current clipboard content + + + Learn more + + + Foundry Local is still in public preview + Do not loc "Foundry Local" + + + Configure the activation shortcut, extensions, behavior and much more + + + Open Command Palette + Command Palette is a product name, do not loc + + + A better quick launcher + + + Find files, launch apps, and do so much more with the most extensible quick launcher. + + + Command Palette + Command Palette is a product name, do not loc + + + Open Command Palette + Command Palette is a product name, do not loc + + + Powerful extensions help you do more + + + Extensible + + + Find files and launch apps in an instant + + + Fast + + + Beautiful + + + A modern UI built with Fluent Design + Fluent Design is a product name, do not loc + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs index 0fdf2ca940..ac051d7cd7 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs @@ -8,17 +8,19 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Globalization; +using System.IO.Abstractions; using System.Linq; -using System.Reflection; +using System.Runtime.Versioning; using System.Text.Json; -using System.Text.Json.Serialization; -using System.Timers; + using global::PowerToys.GPOWrapper; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.UI.Dispatching; using Microsoft.Win32; using Windows.Security.Credentials; @@ -27,22 +29,23 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public partial class AdvancedPasteViewModel : PageViewModelBase { private static readonly HashSet WarnHotkeys = ["Ctrl + V", "Ctrl + Shift + V"]; - private bool _disposed; - // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it; otherwise, we schedule saving it after this interval - private const int SaveSettingsDelayInMs = 500; + private bool _disposed; + private PasteAIProviderDefinition _pasteAIProviderDraft; + private PasteAIProviderDefinition _editingPasteAIProvider; protected override string ModuleName => AdvancedPasteSettings.ModuleName; private GeneralSettings GeneralSettingsConfig { get; set; } private readonly ISettingsUtils _settingsUtils; - private readonly System.Threading.Lock _delayedActionLock = new System.Threading.Lock(); private readonly AdvancedPasteSettings _advancedPasteSettings; private readonly AdvancedPasteAdditionalActions _additionalActions; private readonly ObservableCollection _customActions; - private Timer _delayedTimer; + private readonly DispatcherQueue _dispatcherQueue; + private IFileSystemWatcher _settingsWatcher; + private bool _suppressSave; private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _enabledStateIsGPOConfigured; @@ -52,6 +55,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func SendConfigMSG { get; } + private static readonly HashSet CustomActionNonPersistedProperties = new(StringComparer.Ordinal) + { + nameof(AdvancedPasteCustomAction.CanMoveUp), + nameof(AdvancedPasteCustomAction.CanMoveDown), + nameof(AdvancedPasteCustomAction.IsValid), + nameof(AdvancedPasteCustomAction.HasConflict), + nameof(AdvancedPasteCustomAction.Tooltip), + nameof(AdvancedPasteCustomAction.SubActions), + }; + public AdvancedPasteViewModel( ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, @@ -66,24 +79,28 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // To obtain the settings configurations of Fancy zones. ArgumentNullException.ThrowIfNull(settingsRepository); + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository); _advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig; - _additionalActions = _advancedPasteSettings.Properties.AdditionalActions; - _customActions = _advancedPasteSettings.Properties.CustomActions.Value; - - InitializeEnabledValue(); + AttachConfigurationHandlers(); // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; - _delayedTimer = new Timer(); - _delayedTimer.Interval = SaveSettingsDelayInMs; - _delayedTimer.Elapsed += DelayedTimer_Tick; - _delayedTimer.AutoReset = false; + _additionalActions = _advancedPasteSettings.Properties.AdditionalActions; + _customActions = _advancedPasteSettings.Properties.CustomActions.Value; + + SetupSettingsFileWatcher(); + + InitializePasteAIProviderState(); + + InitializeEnabledValue(); + MigrateLegacyAIEnablement(); foreach (var action in _additionalActions.GetAllActions()) { @@ -144,15 +161,99 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } _onlineAIModelsGpoRuleConfiguration = GPOWrapper.GetAllowedAdvancedPasteOnlineAIModelsValue(); - if (_onlineAIModelsGpoRuleConfiguration == GpoRuleConfigured.Disabled) - { - _onlineAIModelsDisallowedByGPO = true; + _onlineAIModelsDisallowedByGPO = _onlineAIModelsGpoRuleConfiguration == GpoRuleConfigured.Disabled; + if (_onlineAIModelsDisallowedByGPO) + { // disable AI if it was enabled DisableAI(); } } + private void MigrateLegacyAIEnablement() + { + var properties = _advancedPasteSettings?.Properties; + if (properties is null) + { + return; + } + + bool legacyAdvancedAIConsumed = properties.TryConsumeLegacyAdvancedAIEnabled(out var advancedFlag); + bool legacyAdvancedAIEnabled = legacyAdvancedAIConsumed && advancedFlag; + + if (IsOnlineAIModelsDisallowedByGPO) + { + if (legacyAdvancedAIConsumed) + { + SaveAndNotifySettings(); + } + + return; + } + + PasswordCredential legacyCredential = TryGetLegacyOpenAICredential(); + + if (legacyCredential is null) + { + if (legacyAdvancedAIConsumed) + { + SaveAndNotifySettings(); + } + + return; + } + + var configuration = properties.PasteAIConfiguration; + if (configuration is null) + { + configuration = new PasteAIConfiguration(); + properties.PasteAIConfiguration = configuration; + } + + bool configurationUpdated = false; + + var ensureResult = AdvancedPasteMigrationHelper.EnsureOpenAIProvider(configuration); + PasteAIProviderDefinition openAIProvider = ensureResult.Provider; + configurationUpdated |= ensureResult.Updated; + + if (legacyAdvancedAIConsumed && openAIProvider is not null && openAIProvider.EnableAdvancedAI != legacyAdvancedAIEnabled) + { + openAIProvider.EnableAdvancedAI = legacyAdvancedAIEnabled; + configurationUpdated = true; + } + + if (legacyCredential is not null && openAIProvider is not null) + { + SavePasteAIApiKey(openAIProvider.Id, openAIProvider.ServiceType, legacyCredential.Password); + RemoveLegacyOpenAICredential(); + } + + const bool shouldEnableAI = true; + bool enabledChanged = false; + if (properties.IsAIEnabled != shouldEnableAI) + { + properties.IsAIEnabled = shouldEnableAI; + enabledChanged = true; + } + + bool shouldPersist = configurationUpdated || enabledChanged || legacyAdvancedAIConsumed; + + if (shouldPersist) + { + SaveAndNotifySettings(); + + if (configurationUpdated) + { + OnPropertyChanged(nameof(PasteAIConfiguration)); + } + + if (enabledChanged) + { + OnPropertyChanged(nameof(IsAIEnabled)); + } + } + } + public bool IsEnabled { get => _isEnabled; @@ -184,24 +285,43 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions; - private bool OpenAIKeyExists() - { - PasswordVault vault = new PasswordVault(); - PasswordCredential cred = null; + public static IEnumerable AvailableProviders => AIServiceTypeRegistry.GetAvailableServiceTypes(); + /// + /// Gets available AI providers filtered by GPO policies. + /// Only returns providers that are not explicitly disabled by GPO. + /// + public IEnumerable AvailableProvidersFilteredByGPO => + AvailableProviders.Where(metadata => IsServiceTypeAllowedByGPO(metadata.ServiceType)); + + public bool IsAIEnabled => _advancedPasteSettings.Properties.IsAIEnabled && !IsOnlineAIModelsDisallowedByGPO; + + private PasswordCredential TryGetLegacyOpenAICredential() + { try { - cred = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + PasswordVault vault = new(); + var credential = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + credential?.RetrievePassword(); + return credential; } catch (Exception) { - return false; + return null; } - - return cred is not null; } - public bool IsOpenAIEnabled => OpenAIKeyExists() && !IsOnlineAIModelsDisallowedByGPO; + private void RemoveLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + TryRemoveCredential(vault, "https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + } + catch (Exception) + { + } + } public bool IsEnabledGpoConfigured { @@ -347,20 +467,55 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - public bool IsAdvancedAIEnabled + public PasteAIConfiguration PasteAIConfiguration { - get => _advancedPasteSettings.Properties.IsAdvancedAIEnabled; + get => _advancedPasteSettings.Properties.PasteAIConfiguration; set { - if (value != _advancedPasteSettings.Properties.IsAdvancedAIEnabled) + if (!ReferenceEquals(value, _advancedPasteSettings.Properties.PasteAIConfiguration)) { - _advancedPasteSettings.Properties.IsAdvancedAIEnabled = value; - OnPropertyChanged(nameof(IsAdvancedAIEnabled)); - NotifySettingsChanged(); + UnsubscribeFromPasteAIConfiguration(_advancedPasteSettings.Properties.PasteAIConfiguration); + + var newValue = value ?? new PasteAIConfiguration(); + _advancedPasteSettings.Properties.PasteAIConfiguration = newValue; + SubscribeToPasteAIConfiguration(newValue); + + OnPropertyChanged(nameof(PasteAIConfiguration)); + SaveAndNotifySettings(); } } } + public PasteAIProviderDefinition PasteAIProviderDraft + { + get => _pasteAIProviderDraft; + private set + { + if (!ReferenceEquals(_pasteAIProviderDraft, value)) + { + _pasteAIProviderDraft = value; + OnPropertyChanged(nameof(PasteAIProviderDraft)); + OnPropertyChanged(nameof(ShowPasteAIProviderGpoConfiguredInfoBar)); + } + } + } + + public bool ShowPasteAIProviderGpoConfiguredInfoBar + { + get + { + if (_pasteAIProviderDraft is null) + { + return false; + } + + var serviceType = _pasteAIProviderDraft.ServiceType.ToAIServiceType(); + return !IsServiceTypeAllowedByGPO(serviceType); + } + } + + public bool IsEditingPasteAIProvider => _editingPasteAIProvider is not null; + public bool ShowCustomPreview { get => _advancedPasteSettings.Properties.ShowCustomPreview; @@ -387,6 +542,19 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool EnableClipboardPreview + { + get => _advancedPasteSettings.Properties.EnableClipboardPreview; + set + { + if (value != _advancedPasteSettings.Properties.EnableClipboardPreview) + { + _advancedPasteSettings.Properties.EnableClipboardPreview = value; + NotifySettingsChanged(); + } + } + } + public bool IsConflictingCopyShortcut => _customActions.Select(customAction => customAction.Shortcut) .Concat([PasteAsPlainTextShortcut, AdvancedPasteUIShortcut, PasteAsMarkdownShortcut, PasteAsJsonShortcut]) @@ -398,15 +566,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels .Select(additionalAction => additionalAction.Shortcut) .Any(hotkey => WarnHotkeys.Contains(hotkey.ToString())); - private void DelayedTimer_Tick(object sender, EventArgs e) - { - lock (_delayedActionLock) - { - _delayedTimer.Stop(); - NotifySettingsChanged(); - } - } - private void NotifySettingsChanged() { // Using InvariantCulture as this is an IPC message @@ -421,9 +580,178 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public void RefreshEnabledState() { InitializeEnabledValue(); + MigrateLegacyAIEnablement(); OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(ShowOnlineAIModelsGpoConfiguredInfoBar)); OnPropertyChanged(nameof(ShowClipboardHistoryIsGpoConfiguredInfoBar)); + OnPropertyChanged(nameof(IsAIEnabled)); + } + + public void BeginAddPasteAIProvider(string serviceType) + { + var normalizedServiceType = NormalizeServiceType(serviceType, out var persistedServiceType); + + var metadata = AIServiceTypeRegistry.GetMetadata(normalizedServiceType); + var provider = new PasteAIProviderDefinition + { + ServiceType = persistedServiceType, + ModelName = PasteAIProviderDefaults.GetDefaultModelName(normalizedServiceType), + EndpointUrl = string.Empty, + ApiVersion = string.Empty, + DeploymentName = string.Empty, + ModelPath = string.Empty, + SystemPrompt = string.Empty, + ModerationEnabled = normalizedServiceType == AIServiceType.OpenAI, + IsLocalModel = metadata.IsLocalModel, + }; + + if (normalizedServiceType is AIServiceType.FoundryLocal or AIServiceType.Onnx or AIServiceType.ML) + { + provider.ModelName = string.Empty; + } + + _editingPasteAIProvider = null; + PasteAIProviderDraft = provider; + } + + private static AIServiceType NormalizeServiceType(string serviceType, out string persistedServiceType) + { + if (string.IsNullOrWhiteSpace(serviceType)) + { + persistedServiceType = AIServiceType.OpenAI.ToConfigurationString(); + return AIServiceType.OpenAI; + } + + var trimmed = serviceType.Trim(); + var serviceTypeKind = trimmed.ToAIServiceType(); + + if (serviceTypeKind == AIServiceType.Unknown) + { + persistedServiceType = AIServiceType.OpenAI.ToConfigurationString(); + return AIServiceType.OpenAI; + } + + persistedServiceType = trimmed; + return serviceTypeKind; + } + + public bool IsServiceTypeAllowedByGPO(AIServiceType serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + + // Check if this is an online service + if (metadata.IsOnlineService) + { + // For online services, first check the global online AI models GPO + if (_onlineAIModelsGpoRuleConfiguration == GpoRuleConfigured.Disabled) + { + // If global online AI is disabled, all online services are blocked + return false; + } + + // If global online AI is enabled or not configured, check individual endpoint GPO + var individualGpoRule = serviceType switch + { + AIServiceType.OpenAI => GPOWrapper.GetAllowedAdvancedPasteOpenAIValue(), + AIServiceType.AzureOpenAI => GPOWrapper.GetAllowedAdvancedPasteAzureOpenAIValue(), + AIServiceType.AzureAIInference => GPOWrapper.GetAllowedAdvancedPasteAzureAIInferenceValue(), + AIServiceType.Mistral => GPOWrapper.GetAllowedAdvancedPasteMistralValue(), + AIServiceType.Google => GPOWrapper.GetAllowedAdvancedPasteGoogleValue(), + _ => GpoRuleConfigured.Unavailable, + }; + + // If individual GPO is explicitly disabled, block it + return individualGpoRule != GpoRuleConfigured.Disabled; + } + else + { + // For local models, only check their individual GPO (not affected by online AI GPO) + var localGpoRule = serviceType switch + { + AIServiceType.Ollama => GPOWrapper.GetAllowedAdvancedPasteOllamaValue(), + AIServiceType.FoundryLocal => GPOWrapper.GetAllowedAdvancedPasteFoundryLocalValue(), + _ => GpoRuleConfigured.Unavailable, + }; + + // If local model GPO is explicitly disabled, block it + return localGpoRule != GpoRuleConfigured.Disabled; + } + } + + public void BeginEditPasteAIProvider(PasteAIProviderDefinition provider) + { + ArgumentNullException.ThrowIfNull(provider); + + _editingPasteAIProvider = provider; + var draft = provider.Clone(); + var storedEndpoint = GetPasteAIEndpoint(draft.Id, draft.ServiceType); + if (!string.IsNullOrWhiteSpace(storedEndpoint)) + { + draft.EndpointUrl = storedEndpoint; + } + + PasteAIProviderDraft = draft; + } + + public void CancelPasteAIProviderDraft() + { + PasteAIProviderDraft = null; + _editingPasteAIProvider = null; + } + + public void CommitPasteAIProviderDraft(string apiKey, string endpoint) + { + if (PasteAIProviderDraft is null) + { + return; + } + + var config = PasteAIConfiguration ?? new PasteAIConfiguration(); + if (_advancedPasteSettings.Properties.PasteAIConfiguration is null) + { + PasteAIConfiguration = config; + } + + var draft = PasteAIProviderDraft; + draft.EndpointUrl = endpoint?.Trim() ?? string.Empty; + + SavePasteAIApiKey(draft.Id, draft.ServiceType, apiKey); + + if (_editingPasteAIProvider is null) + { + config.Providers.Add(draft); + config.ActiveProviderId ??= draft.Id; + } + else + { + UpdateProviderFromDraft(_editingPasteAIProvider, draft); + _editingPasteAIProvider = null; + } + + PasteAIProviderDraft = null; + SaveAndNotifySettings(); + OnPropertyChanged(nameof(PasteAIConfiguration)); + } + + public void RemovePasteAIProvider(PasteAIProviderDefinition provider) + { + if (provider is null) + { + return; + } + + var config = PasteAIConfiguration; + if (config?.Providers is null) + { + return; + } + + if (config.Providers.Remove(provider)) + { + RemovePasteAICredentials(provider.Id, provider.ServiceType); + SaveAndNotifySettings(); + OnPropertyChanged(nameof(PasteAIConfiguration)); + } } protected override void Dispose(bool disposing) @@ -432,7 +760,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (disposing) { - _delayedTimer?.Dispose(); + UnsubscribeFromPasteAIConfiguration(_advancedPasteSettings?.Properties.PasteAIConfiguration); foreach (var action in _additionalActions.GetAllActions()) { @@ -445,6 +773,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } _customActions.CollectionChanged -= OnCustomActionsCollectionChanged; + _settingsWatcher?.Dispose(); + _settingsWatcher = null; } _disposed = true; @@ -457,10 +787,86 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { try { - PasswordVault vault = new PasswordVault(); - PasswordCredential cred = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); - vault.Remove(cred); - OnPropertyChanged(nameof(IsOpenAIEnabled)); + bool stateChanged = false; + + if (_advancedPasteSettings.Properties.IsAIEnabled) + { + _advancedPasteSettings.Properties.IsAIEnabled = false; + stateChanged = true; + } + + if (stateChanged) + { + SaveAndNotifySettings(); + } + else + { + NotifySettingsChanged(); + } + + OnPropertyChanged(nameof(IsAIEnabled)); + } + catch (Exception) + { + } + } + + internal void EnableAI() + { + try + { + if (IsOnlineAIModelsDisallowedByGPO) + { + return; + } + + bool stateChanged = false; + + if (!_advancedPasteSettings.Properties.IsAIEnabled) + { + _advancedPasteSettings.Properties.IsAIEnabled = true; + stateChanged = true; + } + + if (stateChanged) + { + SaveAndNotifySettings(); + } + else + { + NotifySettingsChanged(); + } + + OnPropertyChanged(nameof(IsAIEnabled)); + } + catch (Exception) + { + } + } + + internal void SavePasteAIApiKey(string providerId, string serviceType, string apiKey) + { + try + { + apiKey = apiKey?.Trim() ?? string.Empty; + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + providerId ??= string.Empty; + + string credentialResource = GetAICredentialResource(serviceType); + string credentialUserName = GetPasteAICredentialUserName(providerId, serviceType); + string endpointCredentialUserName = GetPasteAIEndpointCredentialUserName(providerId, serviceType); + PasswordVault vault = new(); + TryRemoveCredential(vault, credentialResource, credentialUserName); + TryRemoveCredential(vault, credentialResource, endpointCredentialUserName); + + bool storeApiKey = RequiresCredentialStorage(serviceType) && !string.IsNullOrWhiteSpace(apiKey); + if (storeApiKey) + { + PasswordCredential cred = new(credentialResource, credentialUserName, apiKey); + vault.Add(cred); + } + + OnPropertyChanged(nameof(IsAIEnabled)); NotifySettingsChanged(); } catch (Exception) @@ -468,22 +874,138 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - internal void EnableAI(string password) + internal string GetPasteAIApiKey(string providerId, string serviceType) + { + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + providerId ??= string.Empty; + return RetrieveCredentialValue( + GetAICredentialResource(serviceType), + GetPasteAICredentialUserName(providerId, serviceType)); + } + + internal string GetPasteAIEndpoint(string providerId, string serviceType) + { + providerId ??= string.Empty; + var providers = PasteAIConfiguration?.Providers; + if (providers is null) + { + return string.Empty; + } + + var provider = providers.FirstOrDefault(p => string.Equals(p.Id ?? string.Empty, providerId, StringComparison.OrdinalIgnoreCase)); + if (provider is null && !string.IsNullOrWhiteSpace(serviceType)) + { + provider = providers.FirstOrDefault(p => string.Equals(p.ServiceType, serviceType, StringComparison.OrdinalIgnoreCase)); + } + + return provider?.EndpointUrl?.Trim() ?? string.Empty; + } + + private string GetAICredentialResource(string serviceType) + { + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + return serviceType.ToLowerInvariant() switch + { + "openai" => "https://platform.openai.com/api-keys", + "azureopenai" => "https://azure.microsoft.com/products/ai-services/openai-service", + "azureaiinference" => "https://azure.microsoft.com/products/ai-services/ai-inference", + "mistral" => "https://console.mistral.ai/account/api-keys", + "google" => "https://ai.google.dev/", + "ollama" => "https://ollama.com/", + _ => "https://platform.openai.com/api-keys", + }; + } + + private string GetPasteAICredentialUserName(string providerId, string serviceType) + { + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + providerId ??= string.Empty; + + string service = serviceType.ToLowerInvariant(); + string normalizedId = NormalizeProviderIdentifier(providerId); + + return $"PowerToys_AdvancedPaste_PasteAI_{service}_{normalizedId}"; + } + + private string GetPasteAIEndpointCredentialUserName(string providerId, string serviceType) + { + return GetPasteAICredentialUserName(providerId, serviceType) + "_Endpoint"; + } + + private static void UpdateProviderFromDraft(PasteAIProviderDefinition target, PasteAIProviderDefinition source) + { + if (target is null || source is null) + { + return; + } + + target.ServiceType = source.ServiceType; + target.ModelName = source.ModelName; + target.EndpointUrl = source.EndpointUrl; + target.ApiVersion = source.ApiVersion; + target.DeploymentName = source.DeploymentName; + target.ModelPath = source.ModelPath; + target.SystemPrompt = source.SystemPrompt; + target.ModerationEnabled = source.ModerationEnabled; + target.EnableAdvancedAI = source.EnableAdvancedAI; + target.IsLocalModel = source.IsLocalModel; + } + + private void RemovePasteAICredentials(string providerId, string serviceType) { try { + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + providerId ??= string.Empty; + + string credentialResource = GetAICredentialResource(serviceType); PasswordVault vault = new(); - PasswordCredential cred = new("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey", password); - vault.Add(cred); - OnPropertyChanged(nameof(IsOpenAIEnabled)); - IsAdvancedAIEnabled = true; // new users should get Semantic Kernel benefits immediately - NotifySettingsChanged(); + TryRemoveCredential(vault, credentialResource, GetPasteAICredentialUserName(providerId, serviceType)); + TryRemoveCredential(vault, credentialResource, GetPasteAIEndpointCredentialUserName(providerId, serviceType)); } catch (Exception) { } } + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } + + private static bool RequiresCredentialStorage(string serviceType) + { + var serviceTypeKind = serviceType.ToAIServiceType(); + + return serviceTypeKind switch + { + AIServiceType.Onnx => false, + AIServiceType.Ollama => false, + AIServiceType.FoundryLocal => false, + AIServiceType.ML => false, + _ => true, + }; + } + + private static void TryRemoveCredential(PasswordVault vault, string credentialResource, string credentialUserName) + { + try + { + PasswordCredential existingCred = vault.Retrieve(credentialResource, credentialUserName); + vault.Remove(existingCred); + } + catch (Exception) + { + // Credential doesn't exist, which is fine + } + } + internal AdvancedPasteCustomAction GetNewCustomAction(string namePrefix) { ArgumentException.ThrowIfNullOrEmpty(namePrefix); @@ -521,6 +1043,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private void SaveAndNotifySettings() { + if (_suppressSave) + { + return; + } + _settingsUtils.SaveSettings(_advancedPasteSettings.ToJsonString(), AdvancedPasteSettings.ModuleName); NotifySettingsChanged(); } @@ -537,7 +1064,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private void OnCustomActionPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (typeof(AdvancedPasteCustomAction).GetProperty(e.PropertyName).GetCustomAttribute() == null) + if (!string.IsNullOrEmpty(e.PropertyName) && !CustomActionNonPersistedProperties.Contains(e.PropertyName)) { SaveCustomActions(); } @@ -593,6 +1120,324 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels SaveCustomActions(); } + private void AttachConfigurationHandlers() + { + SubscribeToPasteAIConfiguration(_advancedPasteSettings.Properties.PasteAIConfiguration); + } + + private void SetupSettingsFileWatcher() + { + _settingsWatcher = Helper.GetFileWatcher(AdvancedPasteSettings.ModuleName, SettingsUtils.DefaultFileName, OnSettingsFileChanged); + } + + private void OnSettingsFileChanged() + { + if (_disposed) + { + return; + } + + void Handler() + { + ApplyExternalSettings(); + } + + if (_dispatcherQueue is not null && !_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, Handler); + } + else + { + Handler(); + } + } + + private void ApplyExternalSettings() + { + if (_disposed) + { + return; + } + + AdvancedPasteSettings latestSettings; + + try + { + latestSettings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteSettings.ModuleName); + } + catch + { + return; + } + + if (latestSettings?.Properties is null) + { + return; + } + + try + { + _suppressSave = true; + ApplyExternalProperties(latestSettings.Properties); + } + finally + { + _suppressSave = false; + } + } + + private void ApplyExternalProperties(AdvancedPasteProperties source) + { + var target = _advancedPasteSettings?.Properties; + + if (target is null || source is null) + { + return; + } + + if (target.IsAIEnabled != source.IsAIEnabled) + { + target.IsAIEnabled = source.IsAIEnabled; + OnPropertyChanged(nameof(IsAIEnabled)); + } + + if (target.ShowCustomPreview != source.ShowCustomPreview) + { + target.ShowCustomPreview = source.ShowCustomPreview; + OnPropertyChanged(nameof(ShowCustomPreview)); + } + + if (target.CloseAfterLosingFocus != source.CloseAfterLosingFocus) + { + target.CloseAfterLosingFocus = source.CloseAfterLosingFocus; + OnPropertyChanged(nameof(CloseAfterLosingFocus)); + } + + if (target.EnableClipboardPreview != source.EnableClipboardPreview) + { + target.EnableClipboardPreview = source.EnableClipboardPreview; + OnPropertyChanged(nameof(EnableClipboardPreview)); + } + + var incomingConfig = source.PasteAIConfiguration ?? new PasteAIConfiguration(); + if (ShouldReplacePasteAIConfiguration(target.PasteAIConfiguration, incomingConfig)) + { + PasteAIConfiguration = incomingConfig; + } + } + + private static bool ShouldReplacePasteAIConfiguration(PasteAIConfiguration current, PasteAIConfiguration incoming) + { + if (incoming is null) + { + return false; + } + + if (current is null) + { + return true; + } + + if (!string.Equals(current.ActiveProviderId ?? string.Empty, incoming.ActiveProviderId ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var currentProviders = current.Providers ?? new ObservableCollection(); + var incomingProviders = incoming.Providers ?? new ObservableCollection(); + + if (currentProviders.Count != incomingProviders.Count) + { + return true; + } + + for (int i = 0; i < currentProviders.Count; i++) + { + var existing = currentProviders[i]; + var updated = incomingProviders[i]; + + if (!string.Equals(existing?.Id ?? string.Empty, updated?.Id ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.Equals(existing?.ServiceType ?? string.Empty, updated?.ServiceType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.Equals(existing?.ModelName ?? string.Empty, updated?.ModelName ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (!string.Equals(existing?.EndpointUrl ?? string.Empty, updated?.EndpointUrl ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (!string.Equals(existing?.DeploymentName ?? string.Empty, updated?.DeploymentName ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (!string.Equals(existing?.ApiVersion ?? string.Empty, updated?.ApiVersion ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (!string.Equals(existing?.SystemPrompt ?? string.Empty, updated?.SystemPrompt ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (existing?.ModerationEnabled != updated?.ModerationEnabled || existing?.EnableAdvancedAI != updated?.EnableAdvancedAI || existing?.IsActive != updated?.IsActive) + { + return true; + } + } + + return false; + } + + private void SubscribeToPasteAIConfiguration(PasteAIConfiguration configuration) + { + if (configuration is not null) + { + configuration.PropertyChanged += OnPasteAIConfigurationPropertyChanged; + SubscribeToPasteAIProviders(configuration); + } + } + + private void UnsubscribeFromPasteAIConfiguration(PasteAIConfiguration configuration) + { + if (configuration is not null) + { + configuration.PropertyChanged -= OnPasteAIConfigurationPropertyChanged; + UnsubscribeFromPasteAIProviders(configuration); + } + } + + private void SubscribeToPasteAIProviders(PasteAIConfiguration configuration) + { + if (configuration?.Providers is null) + { + return; + } + + configuration.Providers.CollectionChanged -= OnPasteAIProvidersCollectionChanged; + configuration.Providers.CollectionChanged += OnPasteAIProvidersCollectionChanged; + + foreach (var provider in configuration.Providers) + { + provider.PropertyChanged -= OnPasteAIProviderPropertyChanged; + provider.PropertyChanged += OnPasteAIProviderPropertyChanged; + } + } + + private void UnsubscribeFromPasteAIProviders(PasteAIConfiguration configuration) + { + if (configuration?.Providers is null) + { + return; + } + + configuration.Providers.CollectionChanged -= OnPasteAIProvidersCollectionChanged; + + foreach (var provider in configuration.Providers) + { + provider.PropertyChanged -= OnPasteAIProviderPropertyChanged; + } + } + + private void OnPasteAIProvidersCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (e?.NewItems is not null) + { + foreach (PasteAIProviderDefinition provider in e.NewItems) + { + provider.PropertyChanged += OnPasteAIProviderPropertyChanged; + } + } + + if (e?.OldItems is not null) + { + foreach (PasteAIProviderDefinition provider in e.OldItems) + { + provider.PropertyChanged -= OnPasteAIProviderPropertyChanged; + } + } + + var pasteConfig = _advancedPasteSettings?.Properties?.PasteAIConfiguration; + + OnPropertyChanged(nameof(PasteAIConfiguration)); + SaveAndNotifySettings(); + } + + private void OnPasteAIProviderPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (sender is PasteAIProviderDefinition provider) + { + // When service type changes we may need to update credentials entry names. + if (string.Equals(e.PropertyName, nameof(PasteAIProviderDefinition.ServiceType), StringComparison.Ordinal)) + { + SaveAndNotifySettings(); + return; + } + + SaveAndNotifySettings(); + } + } + + private void OnPasteAIConfigurationPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (string.Equals(e.PropertyName, nameof(PasteAIConfiguration.Providers), StringComparison.Ordinal)) + { + SubscribeToPasteAIProviders(PasteAIConfiguration); + SaveAndNotifySettings(); + return; + } + + if (string.Equals(e.PropertyName, nameof(PasteAIConfiguration.ActiveProviderId), StringComparison.Ordinal)) + { + SaveAndNotifySettings(); + } + } + + private void InitializePasteAIProviderState() + { + var pasteConfig = _advancedPasteSettings?.Properties?.PasteAIConfiguration; + if (pasteConfig is null) + { + _advancedPasteSettings.Properties.PasteAIConfiguration = new PasteAIConfiguration(); + pasteConfig = _advancedPasteSettings.Properties.PasteAIConfiguration; + } + + pasteConfig.Providers ??= new ObservableCollection(); + + SubscribeToPasteAIProviders(pasteConfig); + } + + private static string RetrieveCredentialValue(string credentialResource, string credentialUserName) + { + if (string.IsNullOrWhiteSpace(credentialResource) || string.IsNullOrWhiteSpace(credentialUserName)) + { + return string.Empty; + } + + try + { + PasswordVault vault = new(); + PasswordCredential existingCred = vault.Retrieve(credentialResource, credentialUserName); + existingCred?.RetrievePassword(); + return existingCred?.Password?.Trim() ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + private void UpdateCustomActionsCanMoveUpDown() { for (int index = 0; index < _customActions.Count; index++) diff --git a/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs index d9be787e70..d7b03efad4 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs @@ -128,14 +128,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _hotkey) { - if (value == null || value.IsEmpty()) - { - _hotkey = AlwaysOnTopProperties.DefaultHotkeyValue; - } - else - { - _hotkey = value; - } + _hotkey = value ?? AlwaysOnTopProperties.DefaultHotkeyValue; Settings.Properties.Hotkey.Value = _hotkey; NotifyPropertyChanged(); diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs index 7b62732e87..b0520dd38d 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs @@ -22,6 +22,7 @@ using Microsoft.PowerToys.Settings.UI.Services; using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.ViewModels { @@ -29,7 +30,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { protected override string ModuleName => "Dashboard"; - private const string JsonFileType = ".json"; private Dispatcher dispatcher; public Func SendConfigMSG { get; } @@ -40,6 +40,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public ObservableCollection ActionModules { get; set; } = new ObservableCollection(); + // Master list of module items that is sorted and projected into AllModules. + private List _moduleItems = new List(); + + // Flag to prevent circular updates when a UI toggle triggers settings changes. + private bool _isUpdatingFromUI; + private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData(); public AllHotkeyConflictsData AllHotkeyConflictsData @@ -62,6 +68,23 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + private DashboardSortOrder _dashboardSortOrder = DashboardSortOrder.Alphabetical; + + public DashboardSortOrder DashboardSortOrder + { + get => generalSettingsConfig.DashboardSortOrder; + set + { + if (Set(ref _dashboardSortOrder, value)) + { + generalSettingsConfig.DashboardSortOrder = value; + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(generalSettingsConfig); + SendConfigMSG(outgoing.ToString()); + SortModuleList(); + } + } + } + private ISettingsRepository _settingsRepository; private GeneralSettings generalSettingsConfig; private Windows.ApplicationModel.Resources.ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; @@ -73,21 +96,36 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels generalSettingsConfig = settingsRepository.SettingsConfig; generalSettingsConfig.AddEnabledModuleChangeNotification(ModuleEnabledChangedOnSettingsPage); + // Initialize dashboard sort order from settings + _dashboardSortOrder = generalSettingsConfig.DashboardSortOrder; + // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; - foreach (ModuleType moduleType in Enum.GetValues()) - { - AddDashboardListItem(moduleType); - } - - GetShortcutModules(); + BuildModuleList(); + SortModuleList(); + RefreshShortcutModules(); } protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) { dispatcher.BeginInvoke(() => { + var allConflictData = e.Conflicts; + foreach (var inAppConflict in allConflictData.InAppConflicts) + { + var hotkey = inAppConflict.Hotkey; + var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key); + inAppConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting); + } + + foreach (var systemConflict in allConflictData.SystemConflicts) + { + var hotkey = systemConflict.Hotkey; + var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key); + systemConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting); + } + AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData(); }); } @@ -98,47 +136,165 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); } - private void AddDashboardListItem(ModuleType moduleType) + /// + /// Builds the master list of module items. Called once during initialization. + /// Each module item contains its configuration, enabled state, and GPO lock status. + /// + private void BuildModuleList() { - GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); - var newItem = new DashboardListItem() - { - Tag = moduleType, - Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), - IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), - IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, - Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), - DashboardModuleItems = GetModuleItems(moduleType), - }; + _moduleItems.Clear(); - AllModules.Add(newItem); - newItem.EnabledChangedCallback = EnabledChangedOnUI; + foreach (ModuleType moduleType in Enum.GetValues()) + { + GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); + var newItem = new DashboardListItem() + { + Tag = moduleType, + Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), + IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), + IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, + Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), + IsNew = moduleType == ModuleType.CursorWrap, + DashboardModuleItems = GetModuleItems(moduleType), + }; + newItem.EnabledChangedCallback = EnabledChangedOnUI; + _moduleItems.Add(newItem); + } } - private void EnabledChangedOnUI(DashboardListItem dashboardListItem) + /// + /// Sorts the module list according to the current sort order and updates the AllModules collection. + /// On first call, populates AllModules. On subsequent calls, uses Move() to reorder items in-place + /// to avoid destroying and recreating UI elements. + /// + private void SortModuleList() { - Views.ShellPage.UpdateGeneralSettingsCallback(dashboardListItem.Tag, dashboardListItem.IsEnabled); - - if (dashboardListItem.Tag == ModuleType.NewPlus && dashboardListItem.IsEnabled == true) + var sortedItems = (DashboardSortOrder switch { - var settingsUtils = new SettingsUtils(); - var settings = NewPlusViewModel.LoadSettings(settingsUtils); - NewPlusViewModel.CopyTemplateExamples(settings.Properties.TemplateLocation.Value); + DashboardSortOrder.ByStatus => _moduleItems.OrderByDescending(x => x.IsEnabled).ThenBy(x => x.Label), + _ => _moduleItems.OrderBy(x => x.Label), // Default alphabetical + }).ToList(); + + // If AllModules is empty (first load), just populate it. + if (AllModules.Count == 0) + { + foreach (var item in sortedItems) + { + AllModules.Add(item); + } + + return; } - // Request updated conflicts after module state change - RequestConflictData(); + // Otherwise, update the collection in place using Move to avoid UI glitches. + for (int i = 0; i < sortedItems.Count; i++) + { + var currentItem = sortedItems[i]; + var currentIndex = AllModules.IndexOf(currentItem); + + if (currentIndex != -1 && currentIndex != i) + { + AllModules.Move(currentIndex, i); + } + } + + // Notify that DashboardSortOrder changed so the menu updates its checked state. + OnPropertyChanged(nameof(DashboardSortOrder)); } - public void ModuleEnabledChangedOnSettingsPage() + /// + /// Refreshes module enabled/locked states by re-reading GPO configuration. Only + /// updates properties that have actually changed to minimize UI notifications + /// then re-sorts the list according to the current sort order. + /// + private void RefreshModuleList() { + foreach (var item in _moduleItems) + { + GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(item.Tag); + + // GPO can force-enable (Enabled) or force-disable (Disabled) a module. + // If Enabled: module is on and the user cannot disable it. + // If Disabled: module is off and the user cannot enable it. + // Otherwise, the setting is unlocked and the user can enable/disable it. + bool newEnabledState = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, item.Tag)); + + // Lock the toggle when GPO is controlling the module. + bool newLockedState = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled; + + // Only update if there's an actual change to minimize UI notifications. + if (item.IsEnabled != newEnabledState) + { + item.IsEnabled = newEnabledState; + } + + if (item.IsLocked != newLockedState) + { + item.IsLocked = newLockedState; + } + } + + SortModuleList(); + } + + /// + /// Callback invoked when a user toggles a module's enabled state in the UI. + /// Sets the _isUpdatingFromUI flag to prevent circular updates, then updates + /// settings, re-sorts if needed, and refreshes dependent collections. + /// + private void EnabledChangedOnUI(DashboardListItem dashboardListItem) + { + _isUpdatingFromUI = true; try { - GetShortcutModules(); + Views.ShellPage.UpdateGeneralSettingsCallback(dashboardListItem.Tag, dashboardListItem.IsEnabled); + + if (dashboardListItem.Tag == ModuleType.NewPlus && dashboardListItem.IsEnabled == true) + { + var settingsUtils = new SettingsUtils(); + var settings = NewPlusViewModel.LoadSettings(settingsUtils); + NewPlusViewModel.CopyTemplateExamples(settings.Properties.TemplateLocation.Value); + } + + // Re-sort only required if sorting by enabled status. + if (DashboardSortOrder == DashboardSortOrder.ByStatus) + { + SortModuleList(); + } + + // Always refresh shortcuts/actions to reflect enabled state changes. + RefreshShortcutModules(); + + // Request updated conflicts after module state change. + RequestConflictData(); + } + finally + { + _isUpdatingFromUI = false; + } + } + + /// + /// Callback invoked when module enabled state changes from other parts of the + /// settings UI. Ignores the notification if it was triggered by a UI toggle + /// we're already handling, to prevent circular updates. + /// + public void ModuleEnabledChangedOnSettingsPage() + { + // Ignore if this was triggered by a UI change that we're already handling. + if (_isUpdatingFromUI) + { + return; + } + + try + { + RefreshModuleList(); + RefreshShortcutModules(); OnPropertyChanged(nameof(ShortcutModules)); - // Request updated conflicts after module state change + // Request updated conflicts after module state change. RequestConflictData(); } catch (Exception ex) @@ -147,7 +303,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - private void GetShortcutModules() + /// + /// Rebuilds ShortcutModules and ActionModules collections by filtering AllModules + /// to only include enabled modules and their respective shortcut/action items. + /// + private void RefreshShortcutModules() { ShortcutModules.Clear(); ActionModules.Clear(); @@ -212,6 +372,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels ModuleType.FancyZones => GetModuleItemsFancyZones(), ModuleType.FindMyMouse => GetModuleItemsFindMyMouse(), ModuleType.Hosts => GetModuleItemsHosts(), + ModuleType.LightSwitch => GetModuleItemsLightSwitch(), ModuleType.MouseHighlighter => GetModuleItemsMouseHighlighter(), ModuleType.MouseJump => GetModuleItemsMouseJump(), ModuleType.MousePointerCrosshairs => GetModuleItemsMousePointerCrosshairs(), @@ -260,6 +421,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels return new ObservableCollection(list); } + private ObservableCollection GetModuleItemsLightSwitch() + { + ISettingsRepository moduleSettingsRepository = SettingsRepository.GetInstance(new SettingsUtils()); + var settings = moduleSettingsRepository.SettingsConfig; + var list = new List + { + new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("LightSwitch_ForceDarkMode"), Shortcut = settings.Properties.ToggleThemeHotkey.Value.GetKeysList() }, + }; + return new ObservableCollection(list); + } + private ObservableCollection GetModuleItemsCropAndLock() { ISettingsRepository moduleSettingsRepository = SettingsRepository.GetInstance(new SettingsUtils()); diff --git a/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs index 0f0ba98d11..7ce6ec74c6 100644 --- a/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs @@ -776,7 +776,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _editorHotkey) { - if (value == null || value.IsEmpty()) + if (value == null) { _editorHotkey = FZConfigProperties.DefaultEditorHotkeyValue; } @@ -822,7 +822,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _nextTabHotkey) { - if (value == null || value.IsEmpty()) + if (value == null) { _nextTabHotkey = FZConfigProperties.DefaultNextTabHotkeyValue; } @@ -848,7 +848,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _prevTabHotkey) { - if (value == null || value.IsEmpty()) + if (value == null) { _prevTabHotkey = FZConfigProperties.DefaultPrevTabHotkeyValue; } diff --git a/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs index 34b6157d63..04eea7c1e4 100644 --- a/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs @@ -5,8 +5,8 @@ using System; using System.Runtime.CompilerServices; using System.Threading; - using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -33,6 +33,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public ButtonClickCommand LaunchEventHandler => new ButtonClickCommand(Launch); + public ButtonClickCommand SelectBackupPathEventHandler => new ButtonClickCommand(SelectBackupPath); + public bool IsEnabled { get => _isEnabled; @@ -144,6 +146,74 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool BackupHosts + { + get => Settings.Properties.BackupHosts; + set + { + if (value != Settings.Properties.BackupHosts) + { + Settings.Properties.BackupHosts = value; + NotifyPropertyChanged(); + } + } + } + + public string BackupPath + { + get => Settings.Properties.BackupPath; + set + { + if (value != Settings.Properties.BackupPath) + { + Settings.Properties.BackupPath = value; + NotifyPropertyChanged(); + } + } + } + + public int DeleteBackupsMode + { + get => (int)Settings.Properties.DeleteBackupsMode; + set + { + if (value != (int)Settings.Properties.DeleteBackupsMode) + { + Settings.Properties.DeleteBackupsMode = (HostsDeleteBackupMode)value; + NotifyPropertyChanged(); + OnPropertyChanged(nameof(MinimumBackupsCount)); + } + } + } + + public int DeleteBackupsDays + { + get => Settings.Properties.DeleteBackupsDays; + set + { + if (value != Settings.Properties.DeleteBackupsDays) + { + Settings.Properties.DeleteBackupsDays = value; + NotifyPropertyChanged(); + } + } + } + + public int DeleteBackupsCount + { + get => Settings.Properties.DeleteBackupsCount; + set + { + if (value != Settings.Properties.DeleteBackupsCount) + { + Settings.Properties.DeleteBackupsCount = value; + NotifyPropertyChanged(); + } + } + } + + public int MinimumBackupsCount => DeleteBackupsMode == 1 ? 1 : 0; + public HostsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository moduleSettingsRepository, Func ipcMSGCallBackFunc, bool isElevated) { SettingsUtils = settingsUtils; @@ -192,5 +262,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels InitializeEnabledValue(); OnPropertyChanged(nameof(IsEnabled)); } + + public void SelectBackupPath() + { + // This function was changed to use the shell32 API to open folder dialog + // as the old one (PickSingleFolderAsync) can't work when the process is elevated + // TODO: go back PickSingleFolderAsync when it's fixed + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow()); + var result = ShellGetFolder.GetFolderDialog(hwnd); + if (!string.IsNullOrEmpty(result)) + { + BackupPath = result; + } + } } } diff --git a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs new file mode 100644 index 0000000000..621fa91d43 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs @@ -0,0 +1,588 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Windows.Input; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Newtonsoft.Json.Linq; +using Settings.UI.Library; +using Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public partial class LightSwitchViewModel : PageViewModelBase + { + protected override string ModuleName => LightSwitchSettings.ModuleName; + + private Func SendConfigMSG { get; } + + public ObservableCollection SearchLocations { get; } = new(); + + public LightSwitchViewModel(LightSwitchSettings initialSettings = null, Func ipcMSGCallBackFunc = null) + { + _moduleSettings = initialSettings ?? new LightSwitchSettings(); + SendConfigMSG = ipcMSGCallBackFunc; + + ForceLightCommand = new RelayCommand(ForceLightNow); + ForceDarkCommand = new RelayCommand(ForceDarkNow); + + AvailableScheduleModes = new ObservableCollection + { + "Off", + "FixedHours", + "SunsetToSunrise", + }; + + _toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value; + } + + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ToggleThemeActivationShortcut], + }; + + return hotkeysDict; + } + + private void ForceLightNow() + { + Logger.LogInfo("Sending custom action: forceLight"); + SendCustomAction("forceLight"); + } + + private void ForceDarkNow() + { + Logger.LogInfo("Sending custom action: forceDark"); + SendCustomAction("forceDark"); + } + + private void SendCustomAction(string actionName) + { + SendConfigMSG("{\"action\":{\"LightSwitch\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}"); + } + + public LightSwitchSettings ModuleSettings + { + get => _moduleSettings; + set + { + if (_moduleSettings != value) + { + _moduleSettings = value; + + OnPropertyChanged(nameof(ModuleSettings)); + RefreshModuleSettings(); + RefreshEnabledState(); + } + } + } + + public bool IsEnabled + { + get + { + if (_enabledStateIsGPOConfigured) + { + return _enabledGPOConfiguration; + } + else + { + return _isEnabled; + } + } + + set + { + if (_isEnabled != value) + { + if (_enabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + _isEnabled = value; + + RefreshEnabledState(); + + NotifyPropertyChanged(); + } + } + } + + public bool IsEnabledGpoConfigured + { + get => _enabledStateIsGPOConfigured; + set + { + if (_enabledStateIsGPOConfigured != value) + { + _enabledStateIsGPOConfigured = value; + NotifyPropertyChanged(); + } + } + } + + public bool EnabledGPOConfiguration + { + get => _enabledGPOConfiguration; + set + { + if (_enabledGPOConfiguration != value) + { + _enabledGPOConfiguration = value; + NotifyPropertyChanged(); + } + } + } + + public string ScheduleMode + { + get => ModuleSettings.Properties.ScheduleMode.Value; + set + { + var oldMode = ModuleSettings.Properties.ScheduleMode.Value; + if (ModuleSettings.Properties.ScheduleMode.Value != value) + { + ModuleSettings.Properties.ScheduleMode.Value = value; + OnPropertyChanged(nameof(ScheduleMode)); + } + + if (ModuleSettings.Properties.ScheduleMode.Value == "FixedHours" && oldMode != "FixedHours") + { + LightTime = 360; + DarkTime = 1080; + SunsetTimeSpan = null; + SunriseTimeSpan = null; + + OnPropertyChanged(nameof(LightTimePickerValue)); + OnPropertyChanged(nameof(DarkTimePickerValue)); + } + + if (ModuleSettings.Properties.ScheduleMode.Value == "SunsetToSunrise") + { + if (ModuleSettings.Properties.Latitude != "0.0" && ModuleSettings.Properties.Longitude != "0.0") + { + double lat = double.Parse(ModuleSettings.Properties.Latitude.Value, CultureInfo.InvariantCulture); + double lon = double.Parse(ModuleSettings.Properties.Longitude.Value, CultureInfo.InvariantCulture); + UpdateSunTimes(lat, lon); + } + } + } + } + + public ObservableCollection AvailableScheduleModes { get; } + + public bool ChangeSystem + { + get => ModuleSettings.Properties.ChangeSystem.Value; + set + { + if (ModuleSettings.Properties.ChangeSystem.Value != value) + { + ModuleSettings.Properties.ChangeSystem.Value = value; + NotifyPropertyChanged(); + } + } + } + + public bool ChangeApps + { + get => ModuleSettings.Properties.ChangeApps.Value; + set + { + if (ModuleSettings.Properties.ChangeApps.Value != value) + { + ModuleSettings.Properties.ChangeApps.Value = value; + NotifyPropertyChanged(); + } + } + } + + public int LightTime + { + get => ModuleSettings.Properties.LightTime.Value; + set + { + if (ModuleSettings.Properties.LightTime.Value != value) + { + ModuleSettings.Properties.LightTime.Value = value; + NotifyPropertyChanged(); + + OnPropertyChanged(nameof(LightTimeTimeSpan)); + + if (ScheduleMode == "SunsetToSunrise") + { + SunriseTimeSpan = TimeSpan.FromMinutes(value); + } + } + } + } + + public int DarkTime + { + get => ModuleSettings.Properties.DarkTime.Value; + set + { + if (ModuleSettings.Properties.DarkTime.Value != value) + { + ModuleSettings.Properties.DarkTime.Value = value; + NotifyPropertyChanged(); + + OnPropertyChanged(nameof(DarkTimeTimeSpan)); + + if (ScheduleMode == "SunsetToSunrise") + { + SunsetTimeSpan = TimeSpan.FromMinutes(value); + } + } + } + } + + public int SunriseOffset + { + get => ModuleSettings.Properties.SunriseOffset.Value; + set + { + if (ModuleSettings.Properties.SunriseOffset.Value != value) + { + ModuleSettings.Properties.SunriseOffset.Value = value; + OnPropertyChanged(nameof(LightTimeTimeSpan)); + } + } + } + + public int SunsetOffset + { + get => ModuleSettings.Properties.SunsetOffset.Value; + set + { + if (ModuleSettings.Properties.SunsetOffset.Value != value) + { + ModuleSettings.Properties.SunsetOffset.Value = value; + OnPropertyChanged(nameof(DarkTimeTimeSpan)); + } + } + } + + // === Computed projections (OneWay bindings only) === + public TimeSpan LightTimeTimeSpan + { + get + { + if (ScheduleMode == "SunsetToSunrise") + { + return TimeSpan.FromMinutes(LightTime + SunriseOffset); + } + else + { + return TimeSpan.FromMinutes(LightTime); + } + } + } + + public TimeSpan DarkTimeTimeSpan + { + get + { + if (ScheduleMode == "SunsetToSunrise") + { + return TimeSpan.FromMinutes(DarkTime + SunsetOffset); + } + else + { + return TimeSpan.FromMinutes(DarkTime); + } + } + } + + // === Values to pass to timeline === + public TimeSpan? SunriseTimeSpan + { + get => _sunriseTimeSpan; + set + { + if (_sunriseTimeSpan != value) + { + _sunriseTimeSpan = value; + NotifyPropertyChanged(); + } + } + } + + public TimeSpan? SunsetTimeSpan + { + get => _sunsetTimeSpan; + set + { + if (_sunsetTimeSpan != value) + { + _sunsetTimeSpan = value; + NotifyPropertyChanged(); + } + } + } + + // === Picker values (TwoWay binding targets for TimePickers) === + public TimeSpan LightTimePickerValue + { + get => TimeSpan.FromMinutes(LightTime); + set => LightTime = (int)value.TotalMinutes; + } + + public TimeSpan DarkTimePickerValue + { + get => TimeSpan.FromMinutes(DarkTime); + set => DarkTime = (int)value.TotalMinutes; + } + + public string Latitude + { + get => ModuleSettings.Properties.Latitude.Value; + set + { + if (ModuleSettings.Properties.Latitude.Value != value) + { + ModuleSettings.Properties.Latitude.Value = value; + NotifyPropertyChanged(); + } + } + } + + public string Longitude + { + get => ModuleSettings.Properties.Longitude.Value; + set + { + if (ModuleSettings.Properties.Longitude.Value != value) + { + ModuleSettings.Properties.Longitude.Value = value; + NotifyPropertyChanged(); + } + } + } + + private SearchLocation _selectedSearchLocation; + + public SearchLocation SelectedCity + { + get => _selectedSearchLocation; + set + { + if (_selectedSearchLocation != value) + { + _selectedSearchLocation = value; + NotifyPropertyChanged(); + + UpdateSunTimes(_selectedSearchLocation.Latitude, _selectedSearchLocation.Longitude, _selectedSearchLocation.City); + } + } + } + + private string _syncButtonInformation = "Please sync your location"; + + public string SyncButtonInformation + { + get => _syncButtonInformation; + set + { + if (_syncButtonInformation != value) + { + _syncButtonInformation = value; + NotifyPropertyChanged(); + } + } + } + + private double _locationPanelLatitude; + private double _locationPanelLongitude; + + public double LocationPanelLatitude + { + get => _locationPanelLatitude; + set + { + if (_locationPanelLatitude != value) + { + _locationPanelLatitude = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(LocationPanelLightTime)); + } + } + } + + public double LocationPanelLongitude + { + get => _locationPanelLongitude; + set + { + if (_locationPanelLongitude != value) + { + _locationPanelLongitude = value; + NotifyPropertyChanged(); + } + } + } + + private int _locationPanelLightTime; + private int _locationPanelDarkTime; + + public int LocationPanelLightTimeMinutes + { + get => _locationPanelLightTime; + set + { + if (_locationPanelLightTime != value) + { + _locationPanelLightTime = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(LocationPanelLightTime)); + } + } + } + + public int LocationPanelDarkTimeMinutes + { + get => _locationPanelDarkTime; + set + { + if (_locationPanelDarkTime != value) + { + _locationPanelDarkTime = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(LocationPanelDarkTime)); + } + } + } + + public TimeSpan LocationPanelLightTime => TimeSpan.FromMinutes(_locationPanelLightTime); + + public TimeSpan LocationPanelDarkTime => TimeSpan.FromMinutes(_locationPanelDarkTime); + + public HotkeySettings ToggleThemeActivationShortcut + { + get => ModuleSettings.Properties.ToggleThemeHotkey.Value; + + set + { + if (value != ModuleSettings.Properties.ToggleThemeHotkey.Value) + { + if (value == null) + { + ModuleSettings.Properties.ToggleThemeHotkey.Value = LightSwitchProperties.DefaultToggleThemeHotkey; + } + else + { + ModuleSettings.Properties.ToggleThemeHotkey.Value = value; + } + + NotifyPropertyChanged(); + + SendConfigMSG( + string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + LightSwitchSettings.ModuleName, + JsonSerializer.Serialize(_moduleSettings, SourceGenerationContextContext.Default.LightSwitchSettings))); + } + } + } + + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + { + Logger.LogInfo($"Changed the property {propertyName}"); + OnPropertyChanged(propertyName); + } + + public void RefreshEnabledState() + { + OnPropertyChanged(nameof(IsEnabled)); + } + + public void RefreshModuleSettings() + { + OnPropertyChanged(nameof(ChangeSystem)); + OnPropertyChanged(nameof(ChangeApps)); + OnPropertyChanged(nameof(LightTime)); + OnPropertyChanged(nameof(DarkTime)); + OnPropertyChanged(nameof(SunriseOffset)); + OnPropertyChanged(nameof(SunsetOffset)); + OnPropertyChanged(nameof(Latitude)); + OnPropertyChanged(nameof(Longitude)); + OnPropertyChanged(nameof(ScheduleMode)); + } + + private void UpdateSunTimes(double latitude, double longitude, string city = "n/a") + { + SunTimes result = SunCalc.CalculateSunriseSunset( + latitude, + longitude, + DateTime.Now.Year, + DateTime.Now.Month, + DateTime.Now.Day); + + LightTime = (result.SunriseHour * 60) + result.SunriseMinute; + DarkTime = (result.SunsetHour * 60) + result.SunsetMinute; + Latitude = latitude.ToString(CultureInfo.InvariantCulture); + Longitude = longitude.ToString(CultureInfo.InvariantCulture); + + if (city != "n/a") + { + SyncButtonInformation = city; + } + } + + public void InitializeScheduleMode() + { + if (ScheduleMode == "SunsetToSunrise" && + double.TryParse(Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out double savedLat) && + double.TryParse(Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out double savedLng)) + { + var match = SearchLocations.FirstOrDefault(c => + Math.Abs(c.Latitude - savedLat) < 0.0001 && + Math.Abs(c.Longitude - savedLng) < 0.0001); + + if (match != null) + { + SelectedCity = match; + } + + SyncButtonInformation = SelectedCity != null + ? SelectedCity.City + : $"{Latitude}°,{Longitude}°"; + + double lat = double.Parse(ModuleSettings.Properties.Latitude.Value, CultureInfo.InvariantCulture); + double lon = double.Parse(ModuleSettings.Properties.Longitude.Value, CultureInfo.InvariantCulture); + UpdateSunTimes(lat, lon); + + SunriseTimeSpan = TimeSpan.FromMinutes(LightTime); + SunsetTimeSpan = TimeSpan.FromMinutes(DarkTime); + } + } + + private bool _enabledStateIsGPOConfigured; + private bool _enabledGPOConfiguration; + private LightSwitchSettings _moduleSettings; + private bool _isEnabled; + private HotkeySettings _toggleThemeHotkey; + private TimeSpan? _sunriseTimeSpan; + private TimeSpan? _sunsetTimeSpan; + + public ICommand ForceLightCommand { get; } + + public ICommand ForceDarkCommand { get; } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs index 3d845a662b..27695d1037 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs @@ -29,7 +29,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private MousePointerCrosshairsSettings MousePointerCrosshairsSettingsConfig { get; set; } - public MouseUtilsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository findMyMouseSettingsRepository, ISettingsRepository mouseHighlighterSettingsRepository, ISettingsRepository mouseJumpSettingsRepository, ISettingsRepository mousePointerCrosshairsSettingsRepository, Func ipcMSGCallBackFunc) + private CursorWrapSettings CursorWrapSettingsConfig { get; set; } + + public MouseUtilsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository findMyMouseSettingsRepository, ISettingsRepository mouseHighlighterSettingsRepository, ISettingsRepository mouseJumpSettingsRepository, ISettingsRepository mousePointerCrosshairsSettingsRepository, ISettingsRepository cursorWrapSettingsRepository, Func ipcMSGCallBackFunc) { SettingsUtils = settingsUtils; @@ -50,12 +52,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _findMyMouseDoNotActivateOnGameMode = FindMyMouseSettingsConfig.Properties.DoNotActivateOnGameMode.Value; string backgroundColor = FindMyMouseSettingsConfig.Properties.BackgroundColor.Value; - _findMyMouseBackgroundColor = !string.IsNullOrEmpty(backgroundColor) ? backgroundColor : "#000000"; + _findMyMouseBackgroundColor = !string.IsNullOrEmpty(backgroundColor) ? backgroundColor : "#80000000"; string spotlightColor = FindMyMouseSettingsConfig.Properties.SpotlightColor.Value; - _findMyMouseSpotlightColor = !string.IsNullOrEmpty(spotlightColor) ? spotlightColor : "#FFFFFF"; + _findMyMouseSpotlightColor = !string.IsNullOrEmpty(spotlightColor) ? spotlightColor : "#80FFFFFF"; - _findMyMouseOverlayOpacity = FindMyMouseSettingsConfig.Properties.OverlayOpacity.Value; _findMyMouseSpotlightRadius = FindMyMouseSettingsConfig.Properties.SpotlightRadius.Value; _findMyMouseAnimationDurationMs = FindMyMouseSettingsConfig.Properties.AnimationDurationMs.Value; _findMyMouseSpotlightInitialZoom = FindMyMouseSettingsConfig.Properties.SpotlightInitialZoom.Value; @@ -101,8 +102,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _mousePointerCrosshairsAutoHide = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsAutoHide.Value; _mousePointerCrosshairsIsFixedLengthEnabled = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsIsFixedLengthEnabled.Value; _mousePointerCrosshairsFixedLength = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsFixedLength.Value; + _mousePointerCrosshairsOrientation = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value; _mousePointerCrosshairsAutoActivate = MousePointerCrosshairsSettingsConfig.Properties.AutoActivate.Value; + ArgumentNullException.ThrowIfNull(cursorWrapSettingsRepository); + + CursorWrapSettingsConfig = cursorWrapSettingsRepository.SettingsConfig; + _cursorWrapAutoActivate = CursorWrapSettingsConfig.Properties.AutoActivate.Value; + + // Null-safe access in case property wasn't upgraded yet - default to TRUE + _cursorWrapDisableWrapDuringDrag = CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag?.Value ?? true; + int isEnabled = 0; Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0); @@ -144,13 +154,25 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) { // Get the enabled state from GPO. - _mousePointerCrosshairsEnabledStateIsGPOConfigured = true; + _mousePointerCrosshairsEnabledStateGPOConfigured = true; _isMousePointerCrosshairsEnabled = _mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; } else { _isMousePointerCrosshairsEnabled = GeneralSettingsConfig.Enabled.MousePointerCrosshairs; } + + _cursorWrapEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredCursorWrapEnabledValue(); + if (_cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + _cursorWrapEnabledStateIsGPOConfigured = true; + _isCursorWrapEnabled = _cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + } + else + { + _isCursorWrapEnabled = GeneralSettingsConfig.Enabled.CursorWrap; + } } public override Dictionary GetAllHotkeySettings() @@ -163,6 +185,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels MousePointerCrosshairsActivationShortcut, GlidingCursorActivationShortcut], [MouseJumpSettings.ModuleName] = [MouseJumpActivationShortcut], + [CursorWrapSettings.ModuleName] = [CursorWrapActivationShortcut], }; return hotkeysDict; @@ -279,7 +302,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels set { - value = (value != null) ? SettingsUtilities.ToRGBHex(value) : "#000000"; + value = (value != null) ? SettingsUtilities.ToARGBHex(value) : "#FF000000"; if (!value.Equals(_findMyMouseBackgroundColor, StringComparison.OrdinalIgnoreCase)) { _findMyMouseBackgroundColor = value; @@ -298,7 +321,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels set { - value = (value != null) ? SettingsUtilities.ToRGBHex(value) : "#FFFFFF"; + value = (value != null) ? SettingsUtilities.ToARGBHex(value) : "#FFFFFFFF"; if (!value.Equals(_findMyMouseSpotlightColor, StringComparison.OrdinalIgnoreCase)) { _findMyMouseSpotlightColor = value; @@ -308,24 +331,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - public int FindMyMouseOverlayOpacity - { - get - { - return _findMyMouseOverlayOpacity; - } - - set - { - if (value != _findMyMouseOverlayOpacity) - { - _findMyMouseOverlayOpacity = value; - FindMyMouseSettingsConfig.Properties.OverlayOpacity.Value = value; - NotifyFindMyMousePropertyChanged(); - } - } - } - public int FindMyMouseSpotlightRadius { get @@ -681,7 +686,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels get => _isMousePointerCrosshairsEnabled; set { - if (_mousePointerCrosshairsEnabledStateIsGPOConfigured) + if (_mousePointerCrosshairsEnabledStateGPOConfigured) { // If it's GPO configured, shouldn't be able to change this state. return; @@ -704,7 +709,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public bool IsMousePointerCrosshairsEnabledGpoConfigured { - get => _mousePointerCrosshairsEnabledStateIsGPOConfigured; + get => _mousePointerCrosshairsEnabledStateGPOConfigured; } public HotkeySettings MousePointerCrosshairsActivationShortcut @@ -888,6 +893,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public int MousePointerCrosshairsOrientation + { + get + { + return _mousePointerCrosshairsOrientation; + } + + set + { + if (value != _mousePointerCrosshairsOrientation) + { + _mousePointerCrosshairsOrientation = value; + MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value = value; + NotifyMousePointerCrosshairsPropertyChanged(); + } + } + } + public bool MousePointerCrosshairsAutoActivate { get @@ -959,6 +982,117 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels SettingsUtils.SaveSettings(MousePointerCrosshairsSettingsConfig.ToJsonString(), MousePointerCrosshairsSettings.ModuleName); } + public bool IsCursorWrapEnabled + { + get => _isCursorWrapEnabled; + set + { + if (_cursorWrapEnabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + if (_isCursorWrapEnabled != value) + { + _isCursorWrapEnabled = value; + + GeneralSettingsConfig.Enabled.CursorWrap = value; + OnPropertyChanged(nameof(IsCursorWrapEnabled)); + + // Auto-enable the AutoActivate setting when CursorWrap is enabled + // This ensures cursor wrapping is active immediately after enabling + if (value && !_cursorWrapAutoActivate) + { + CursorWrapAutoActivate = true; + } + + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig); + SendConfigMSG(outgoing.ToString()); + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool IsCursorWrapEnabledGpoConfigured + { + get => _cursorWrapEnabledStateIsGPOConfigured; + } + + public HotkeySettings CursorWrapActivationShortcut + { + get + { + return CursorWrapSettingsConfig.Properties.ActivationShortcut; + } + + set + { + if (CursorWrapSettingsConfig.Properties.ActivationShortcut != value) + { + CursorWrapSettingsConfig.Properties.ActivationShortcut = value ?? CursorWrapSettingsConfig.Properties.DefaultActivationShortcut; + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapAutoActivate + { + get + { + return _cursorWrapAutoActivate; + } + + set + { + if (value != _cursorWrapAutoActivate) + { + _cursorWrapAutoActivate = value; + CursorWrapSettingsConfig.Properties.AutoActivate.Value = value; + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapDisableWrapDuringDrag + { + get + { + return _cursorWrapDisableWrapDuringDrag; + } + + set + { + if (value != _cursorWrapDisableWrapDuringDrag) + { + _cursorWrapDisableWrapDuringDrag = value; + + // Ensure the property exists before setting value + if (CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag == null) + { + CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag = new BoolProperty(value); + } + else + { + CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag.Value = value; + } + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public void NotifyCursorWrapPropertyChanged([CallerMemberName] string propertyName = null) + { + OnPropertyChanged(propertyName); + + SndCursorWrapSettings outsettings = new SndCursorWrapSettings(CursorWrapSettingsConfig); + SndModuleSettings ipcMessage = new SndModuleSettings(outsettings); + SendConfigMSG(ipcMessage.ToJsonString()); + SettingsUtils.SaveSettings(CursorWrapSettingsConfig.ToJsonString(), CursorWrapSettings.ModuleName); + } + public void RefreshEnabledState() { InitializeEnabledValues(); @@ -966,6 +1100,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsMouseHighlighterEnabled)); OnPropertyChanged(nameof(IsMouseJumpEnabled)); OnPropertyChanged(nameof(IsMousePointerCrosshairsEnabled)); + OnPropertyChanged(nameof(IsCursorWrapEnabled)); } private Func SendConfigMSG { get; } @@ -978,7 +1113,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _findMyMouseDoNotActivateOnGameMode; private string _findMyMouseBackgroundColor; private string _findMyMouseSpotlightColor; - private int _findMyMouseOverlayOpacity; private int _findMyMouseSpotlightRadius; private int _findMyMouseAnimationDurationMs; private int _findMyMouseSpotlightInitialZoom; @@ -1000,7 +1134,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _highlighterAutoActivate; private GpoRuleConfigured _mousePointerCrosshairsEnabledGpoRuleConfiguration; - private bool _mousePointerCrosshairsEnabledStateIsGPOConfigured; + private bool _mousePointerCrosshairsEnabledStateGPOConfigured; private bool _isMousePointerCrosshairsEnabled; private string _mousePointerCrosshairsColor; private int _mousePointerCrosshairsOpacity; @@ -1011,7 +1145,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _mousePointerCrosshairsAutoHide; private bool _mousePointerCrosshairsIsFixedLengthEnabled; private int _mousePointerCrosshairsFixedLength; + private int _mousePointerCrosshairsOrientation; private bool _mousePointerCrosshairsAutoActivate; private bool _isAnimationEnabledBySystem; + + private GpoRuleConfigured _cursorWrapEnabledGpoRuleConfiguration; + private bool _cursorWrapEnabledStateIsGPOConfigured; + private bool _isCursorWrapEnabled; + private bool _cursorWrapAutoActivate; + private bool _cursorWrapDisableWrapDuringDrag; // Will be initialized in constructor from settings } } diff --git a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs index 3688e2e14d..85ffbda2d9 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs @@ -170,6 +170,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (_peekSettings.Properties.ActivationShortcut != value) { + // If space mode toggle is on, ignore external attempts to change (UI will be disabled, but defensive). + if (EnableSpaceToActivate) + { + return; + } + _peekSettings.Properties.ActivationShortcut = value ?? _peekSettings.Properties.DefaultActivationShortcut; OnPropertyChanged(nameof(ActivationShortcut)); NotifySettingsChanged(); @@ -219,6 +225,33 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool EnableSpaceToActivate + { + get => _peekSettings.Properties.EnableSpaceToActivate.Value; + set + { + if (_peekSettings.Properties.EnableSpaceToActivate.Value != value) + { + _peekSettings.Properties.EnableSpaceToActivate.Value = value; + + if (value) + { + // Force single space (0x20) without modifiers. + _peekSettings.Properties.ActivationShortcut = new HotkeySettings(false, false, false, false, 0x20); + } + else + { + // Revert to default (design simplification, not restoring previous custom combo). + _peekSettings.Properties.ActivationShortcut = _peekSettings.Properties.DefaultActivationShortcut; + } + + OnPropertyChanged(nameof(EnableSpaceToActivate)); + OnPropertyChanged(nameof(ActivationShortcut)); + NotifySettingsChanged(); + } + } + } + public bool SourceCodeWrapText { get => _peekPreviewSettings.SourceCodeWrapText.Value; diff --git a/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs index 2cfcbaf42f..cfd3683080 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs @@ -12,12 +12,10 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using System.Windows; using System.Windows.Threading; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.SerializationContext; @@ -70,6 +68,36 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels protected override string ModuleName => "ShortcutConflictsWindow"; + /// + /// Ignore a specific HotkeySettings + /// + /// The HotkeySettings to ignore + public void IgnoreShortcut(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return; + } + + HotkeyConflictIgnoreHelper.AddToIgnoredList(hotkeySettings); + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + + /// + /// Remove a HotkeySettings from the ignored list + /// + /// The HotkeySettings to unignore + public void UnignoreShortcut(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return; + } + + HotkeyConflictIgnoreHelper.RemoveFromIgnoredList(hotkeySettings); + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + private IHotkeyConfig GetModuleSettings(string moduleKey) { try @@ -120,20 +148,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels foreach (var conflict in conflicts) { - ProcessConflictGroup(conflict, isSystemConflict); + HotkeySettings hotkey = new(conflict.Hotkey.Win, conflict.Hotkey.Ctrl, conflict.Hotkey.Alt, conflict.Hotkey.Shift, conflict.Hotkey.Key); + var isIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkey); + conflict.ConflictIgnored = isIgnored; + + ProcessConflictGroup(conflict, isSystemConflict, isIgnored); items.Add(conflict); } } - private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict) + private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict, bool isIgnored) { foreach (var module in conflict.Modules) { - SetupModuleData(module, isSystemConflict); + SetupModuleData(module, isSystemConflict, isIgnored); } } - private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict) + private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict, bool isIgnored) { try { @@ -220,55 +252,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - private void SaveModuleSettingsAndNotify(string moduleName) - { - try - { - var settings = GetModuleSettings(moduleName); - - if (settings is ISettingsConfig settingsConfig) - { - // No need to save settings here, the runner will call module interface to save it - // SaveSettingsToFile(settings); - - // Send IPC notification using the same format as other ViewModels - SendConfigMSG(settingsConfig, moduleName); - - System.Diagnostics.Debug.WriteLine($"Saved settings and sent IPC notification for module: {moduleName}"); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error saving settings and notifying for {moduleName}: {ex.Message}"); - } - } - - private void SaveSettingsToFile(IHotkeyConfig settings) - { - try - { - // Get the repository for this settings type using reflection - var settingsType = settings.GetType(); - var repositoryMethod = typeof(SettingsFactory).GetMethod("GetRepository"); - if (repositoryMethod != null) - { - var genericMethod = repositoryMethod.MakeGenericMethod(settingsType); - var repository = genericMethod.Invoke(_settingsFactory, null); - - if (repository != null) - { - var saveMethod = repository.GetType().GetMethod("SaveSettingsToFile"); - saveMethod?.Invoke(repository, null); - System.Diagnostics.Debug.WriteLine($"Saved settings to file for type: {settingsType.Name}"); - } - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error saving settings to file: {ex.Message}"); - } - } - /// /// Sends IPC notification using the same format as other ViewModels /// diff --git a/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs index 2c05c79358..842c3cf368 100644 --- a/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs @@ -127,7 +127,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _hotkey) { - if (value == null || value.IsEmpty()) + if (value == null) { _hotkey = WorkspacesProperties.DefaultHotkeyValue; } diff --git a/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs index 8a277bbfa5..e1704e16fb 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs @@ -24,6 +24,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { public class ZoomItViewModel : Observable { + private const string FormatGif = "GIF"; + private const string FormatMp4 = "MP4"; + private ISettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -197,6 +200,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool SmoothImage + { + get => _zoomItSettings.Properties.SmoothImage.Value; + set + { + if (_zoomItSettings.Properties.SmoothImage.Value != value) + { + _zoomItSettings.Properties.SmoothImage.Value = value; + OnPropertyChanged(nameof(SmoothImage)); + NotifySettingsChanged(); + } + } + } + public int ZoominSliderLevel { get => _zoomItSettings.Properties.ZoominSliderLevel.Value; @@ -638,6 +655,54 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public int RecordFormatIndex + { + get + { + if (_zoomItSettings.Properties.RecordFormat.Value == FormatGif) + { + return 0; + } + + if (_zoomItSettings.Properties.RecordFormat.Value == FormatMp4) + { + return 1; + } + + return 0; + } + + set + { + int format = 0; + if (_zoomItSettings.Properties.RecordFormat.Value == FormatGif) + { + format = 0; + } + + if (_zoomItSettings.Properties.RecordFormat.Value == FormatMp4) + { + format = 1; + } + + if (format != value) + { + _zoomItSettings.Properties.RecordFormat.Value = value == 0 ? FormatGif : FormatMp4; + OnPropertyChanged(nameof(RecordFormatIndex)); + NotifySettingsChanged(); + + // Reload settings to get the new format's scaling value + var reloadedSettings = global::PowerToys.ZoomItSettingsInterop.ZoomItSettings.LoadSettingsJson(); + var reloaded = JsonSerializer.Deserialize(reloadedSettings, _serializerOptions); + if (reloaded != null && reloaded.Properties != null) + { + _zoomItSettings.Properties.RecordScaling.Value = reloaded.Properties.RecordScaling.Value; + OnPropertyChanged(nameof(RecordScalingIndex)); + } + } + } + } + public bool RecordCaptureAudio { get => _zoomItSettings.Properties.CaptureAudio.Value; diff --git a/src/settings-ui/settings-ui.instructions.md b/src/settings-ui/settings-ui.instructions.md new file mode 100644 index 0000000000..cf527b69f0 --- /dev/null +++ b/src/settings-ui/settings-ui.instructions.md @@ -0,0 +1,17 @@ +--- +applyTo: "**/*.cs,**/*.xaml" +--- +# Settings UI – configuration app guidance + +Scope +- WinUI/WPF UI, communicates with Runner over named pipes; manages persisted settings schema. + +Guidelines +- Don’t break settings schema silently; add migration when shape changes. +- If IPC/JSON contracts change, align with `src/runner/**` implementation. +- Keep UI responsive: marshal to UI thread for UI-bound operations. +- Reuse existing styles/resources; avoid duplicate theme keys. +- Add/adjust migration or serialization tests when changing persisted settings. + +Acceptance +- Schema integrity preserved, responsive UI, consistent contracts, no style duplication. \ No newline at end of file diff --git a/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj b/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj index 734146a663..f56b2646b4 100644 --- a/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj +++ b/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj @@ -30,6 +30,7 @@ NotUsing ../../../src/ + true Console @@ -37,10 +38,6 @@ - - - 4706;26451;4267;4244;%(DisableSpecificWarnings) - @@ -61,8 +58,6 @@ - - diff --git a/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj.filters b/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj.filters index 0723bd31ac..f8117733d9 100644 --- a/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj.filters +++ b/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj.filters @@ -8,7 +8,6 @@ ZipTools - @@ -28,8 +27,6 @@ ZipTools - - diff --git a/tools/BugReportTool/BugReportTool/ProcessesList.cpp b/tools/BugReportTool/BugReportTool/ProcessesList.cpp index 1aa1d1d708..eb4a976c9b 100644 --- a/tools/BugReportTool/BugReportTool/ProcessesList.cpp +++ b/tools/BugReportTool/BugReportTool/ProcessesList.cpp @@ -11,6 +11,7 @@ std::vector processes = L"PowerToys.FancyZonesEditor.exe", L"PowerToys.FancyZones.exe", L"PowerToys.FileLocksmithUI.exe", + L"PowerToys.LightSwitch.exe", L"PowerToys.KeyboardManagerEngine.exe", L"PowerToys.KeyboardManagerEditor.exe", L"PowerToys.PowerAccent.exe", diff --git a/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp b/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp index 3c042ee06d..edd48839d2 100644 --- a/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp +++ b/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp @@ -50,6 +50,7 @@ void ReportGPOValues(const std::filesystem::path &tmpDir) report << "getConfiguredCropAndLockEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredCropAndLockEnabledValue()) << std::endl; report << "getConfiguredFancyZonesEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredFancyZonesEnabledValue()) << std::endl; report << "getConfiguredFileLocksmithEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredFileLocksmithEnabledValue()) << std::endl; + report << "getConfiguredLightSwitchEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredLightSwitchEnabledValue()) << std::endl; report << "getConfiguredSvgPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredSvgPreviewEnabledValue()) << std::endl; report << "getConfiguredMarkdownPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMarkdownPreviewEnabledValue()) << std::endl; report << "getConfiguredMonacoPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMonacoPreviewEnabledValue()) << std::endl; @@ -86,6 +87,13 @@ void ReportGPOValues(const std::filesystem::path &tmpDir) report << "getConfiguredQoiPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredQoiPreviewEnabledValue()) << std::endl; report << "getConfiguredQoiThumbnailsEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredQoiThumbnailsEnabledValue()) << std::endl; report << "getAllowedAdvancedPasteOnlineAIModelsValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue()) << std::endl; + report << "getAllowedAdvancedPasteOpenAIValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteOpenAIValue()) << std::endl; + report << "getAllowedAdvancedPasteAzureOpenAIValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteAzureOpenAIValue()) << std::endl; + report << "getAllowedAdvancedPasteAzureAIInferenceValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteAzureAIInferenceValue()) << std::endl; + report << "getAllowedAdvancedPasteMistralValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteMistralValue()) << std::endl; + report << "getAllowedAdvancedPasteGoogleValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteGoogleValue()) << std::endl; + report << "getAllowedAdvancedPasteOllamaValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteOllamaValue()) << std::endl; + report << "getAllowedAdvancedPasteFoundryLocalValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue()) << std::endl; report << "getConfiguredMwbClipboardSharingEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMwbClipboardSharingEnabledValue()) << std::endl; report << "getConfiguredMwbFileTransferEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMwbFileTransferEnabledValue()) << std::endl; report << "getConfiguredMwbUseOriginalUserInterfaceValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMwbUseOriginalUserInterfaceValue()) << std::endl; diff --git a/tools/BugReportTool/BugReportTool/XmlDocumentEx.cpp b/tools/BugReportTool/BugReportTool/XmlDocumentEx.cpp index d2e71705b1..3fee8f927e 100644 --- a/tools/BugReportTool/BugReportTool/XmlDocumentEx.cpp +++ b/tools/BugReportTool/BugReportTool/XmlDocumentEx.cpp @@ -19,13 +19,13 @@ void XmlDocumentEx::Print(winrt::Windows::Data::Xml::Dom::IXmlNode node, int ind PrintTagWithAttributes(node); if (!node.HasChildNodes()) { - stream << L"<\\" << node.NodeName().c_str() << ">" << std::endl; + stream << L"" << std::endl; return; } if (node.ChildNodes().Size() == 1 && !node.FirstChild().HasChildNodes()) { - stream << node.InnerText().c_str() << L"<\\" << node.NodeName().c_str() << ">" << std::endl; + stream << node.InnerText().c_str() << L"" << std::endl; return; } @@ -40,7 +40,7 @@ void XmlDocumentEx::Print(winrt::Windows::Data::Xml::Dom::IXmlNode node, int ind { stream << " "; } - stream << L"<\\" << node.NodeName().c_str() << ">" << std::endl; + stream << L"" << std::endl; } void XmlDocumentEx::PrintTagWithAttributes(winrt::Windows::Data::Xml::Dom::IXmlNode node) diff --git a/tools/BugReportTool/BugReportTool/ZipTools/zipfolder.cpp b/tools/BugReportTool/BugReportTool/ZipTools/zipfolder.cpp index 6b5f4f0180..476707bd67 100644 --- a/tools/BugReportTool/BugReportTool/ZipTools/zipfolder.cpp +++ b/tools/BugReportTool/BugReportTool/ZipTools/zipfolder.cpp @@ -1,50 +1,53 @@ #include "ZipFolder.h" -#include "..\..\..\..\deps\cziplib\src\zip.h" #include +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include + void ZipFolder(std::filesystem::path zipPath, std::filesystem::path folderPath) { - std::string reportFilename{ "PowerToysReport_" }; - reportFilename += timeutil::format_as_local("%F-%H-%M-%S", timeutil::now()); - reportFilename += ".zip"; + const auto reportFilename{ + std::format("PowerToysReport_{0}.zip", + timeutil::format_as_local("%F-%H-%M-%S", timeutil::now())) + }; + const auto finalReportFullPath{ zipPath / reportFilename }; - auto tmpZipPath = std::filesystem::temp_directory_path(); - tmpZipPath /= reportFilename; + const auto tempReportFilename{ reportFilename + ".tmp" }; + const auto tempReportFullPath{ zipPath / tempReportFilename }; - struct zip_t* zip = zip_open(tmpZipPath.string().c_str(), ZIP_DEFAULT_COMPRESSION_LEVEL, 'w'); - if (!zip) + // tar -c --format=zip -f "ReportFile.zip" * + const auto executable{ wil::ExpandEnvironmentStringsW(LR"(%WINDIR%\System32\tar.exe)") }; + auto commandline{ std::format(LR"("{0}" -c --format=zip -f "{1}" *)", executable, tempReportFullPath.wstring()) }; + + const auto folderPathAsString{ folderPath.lexically_normal().wstring() }; + + wil::unique_process_information pi; + STARTUPINFOW si{ .cb = sizeof(STARTUPINFOW) }; + if (!CreateProcessW(executable.c_str(), + commandline.data() /* must be mutable */, + nullptr, + nullptr, + FALSE, + DETACHED_PROCESS, + nullptr, + folderPathAsString.c_str(), + &si, + &pi)) { printf("Cannot open zip."); throw -1; } - using recursive_directory_iterator = std::filesystem::recursive_directory_iterator; - const size_t rootSize = folderPath.wstring().size(); - for (const auto& dirEntry : recursive_directory_iterator(folderPath)) - { - if (dirEntry.is_regular_file()) - { - auto path = dirEntry.path().string(); - auto relativePath = path.substr(rootSize, path.size()); - zip_entry_open(zip, relativePath.c_str()); - zip_entry_fwrite(zip, path.c_str()); - zip_entry_close(zip); - } - } + WaitForSingleObject(pi.hProcess, INFINITE); - zip_close(zip); - - std::error_code err; - std::filesystem::copy(tmpZipPath, zipPath, err); + std::error_code err{}; + std::filesystem::rename(tempReportFullPath, finalReportFullPath, err); if (err.value() != 0) { - wprintf_s(L"Failed to copy %s. Error code: %d\n", tmpZipPath.c_str(), err.value()); + wprintf_s(L"Failed to rename %s. Error code: %d\n", tempReportFullPath.native().c_str(), err.value()); } - - err = {}; - std::filesystem::remove_all(tmpZipPath, err); - if (err.value() != 0) - { - wprintf_s(L"Failed to delete %s. Error code: %d\n", tmpZipPath.c_str(), err.value()); - } -} \ No newline at end of file +} diff --git a/tools/build/Delete-Worktree.cmd b/tools/build/Delete-Worktree.cmd new file mode 100644 index 0000000000..edf14bb537 --- /dev/null +++ b/tools/build/Delete-Worktree.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %* diff --git a/tools/build/Delete-Worktree.ps1 b/tools/build/Delete-Worktree.ps1 new file mode 100644 index 0000000000..68f7c218d4 --- /dev/null +++ b/tools/build/Delete-Worktree.ps1 @@ -0,0 +1,130 @@ +<#! +.SYNOPSIS + Remove a git worktree (and optionally its local branch and orphan fork remote). + +.DESCRIPTION + Locates a worktree by branch/path pattern (supports wildcards). Ensures the primary repository + root is never removed. Optionally discards local changes with -Force. Deletes associated branch + unless -KeepBranch. If the branch tracked a non-origin remote with no remaining tracking + branches, that remote is removed unless -KeepRemote. + +.PARAMETER Pattern + Branch name or path fragment (wildcards * ? allowed). If multiple matches found they are listed + and no deletion occurs. + +.PARAMETER Force + Discard uncommitted changes and attempt aggressive cleanup on failure. + +.PARAMETER KeepBranch + Preserve the local branch (only remove the worktree directory entry). + +.PARAMETER KeepRemote + Preserve any orphan fork remote even if no branches still track it. + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern feature/login + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern fork-user-featureX -Force + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern hotfix -KeepBranch + +.NOTES + Manual recovery: + git worktree list --porcelain + git worktree prune + Remove-Item -LiteralPath -Recurse -Force + git branch -D + git remote remove + git worktree prune +#> + +param( + [string] $Pattern, + [switch] $Force, + [switch] $KeepBranch, + [switch] $KeepRemote, + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" +if ($Help -or -not $Pattern) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } +try { + $repoRoot = Get-RepoRoot + $entries = Get-WorktreeEntries + if (-not $entries -or $entries.Count -eq 0) { throw 'No worktrees found.' } + $hasWildcard = $Pattern -match '[\*\?]' + $matchPattern = if ($hasWildcard) { $Pattern } else { "*${Pattern}*" } + $found = $entries | Where-Object { $_.Branch -and ( $_.Branch -like $matchPattern -or $_.Path -like $matchPattern ) } + if (-not $found -or $found.Count -eq 0) { throw "No worktree matches pattern '$Pattern'" } + if ($found.Count -gt 1) { + Warn 'Pattern matches multiple worktrees:' + $found | ForEach-Object { Info (" {0} {1}" -f $_.Branch, $_.Path) } + return + } + $target = $found | Select-Object -First 1 + $branch = $target.Branch + $folder = $target.Path + if (-not $branch) { throw 'Resolved worktree has no branch (detached); refusing removal.' } + try { $folder = (Resolve-Path -LiteralPath $folder -ErrorAction Stop).ProviderPath } catch {} + $primary = (Resolve-Path -LiteralPath $repoRoot).ProviderPath + if ([IO.Path]::GetFullPath($folder).TrimEnd('\\/') -ieq [IO.Path]::GetFullPath($primary).TrimEnd('\\/')) { throw 'Refusing to remove the primary worktree (repository root).' } + $status = git -C $folder status --porcelain 2>$null + if ($LASTEXITCODE -ne 0) { throw "Unable to get git status for $folder" } + if (-not $Force -and $status) { throw 'Worktree has uncommitted changes. Use -Force to discard.' } + if ($Force -and $status) { + Warn '[Force] Discarding local changes' + git -C $folder reset --hard HEAD | Out-Null + git -C $folder clean -fdx | Out-Null + } + if ($Force) { git worktree remove --force $folder } else { git worktree remove $folder } + if ($LASTEXITCODE -ne 0) { + $exit1 = $LASTEXITCODE + $errMsg = "git worktree remove failed (exit $exit1)" + if ($Force) { + Warn 'Primary removal failed; performing aggressive fallback (Force implies brute).' + try { git -C $folder submodule deinit -f --all 2>$null | Out-Null } catch {} + try { git -C $folder clean -dfx 2>$null | Out-Null } catch {} + try { Get-ChildItem -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { try { $_.IsReadOnly = $false } catch {} } } catch {} + if (Test-Path $folder) { try { Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction Stop } catch { Err "Manual directory removal failed: $($_.Exception.Message)" } } + git worktree prune 2>$null | Out-Null + if (Test-Path $folder) { throw "$errMsg and aggressive cleanup did not fully remove directory: $folder" } else { Info "Aggressive cleanup removed directory $folder." } + } else { + throw "$errMsg. Rerun with -Force to attempt aggressive cleanup." + } + } + # Determine upstream before potentially deleting branch + $upRemote = Get-BranchUpstreamRemote -Branch $branch + $looksForkName = $branch -like 'fork-*' + + if (-not $KeepBranch) { + git branch -D $branch 2>$null | Out-Null + if (-not $KeepRemote -and $upRemote -and $upRemote -ne 'origin') { + $otherTracking = git for-each-ref --format='%(refname:short)|%(upstream:short)' refs/heads 2>$null | + Where-Object { $_ -and ($_ -notmatch "^$branch\|") } | + ForEach-Object { $parts = $_.Split('|',2); if ($parts[1] -match '^(?[^/]+)/'){ $parts[0],$Matches.r } } | + Where-Object { $_[1] -eq $upRemote } + if (-not $otherTracking) { + Warn "Removing orphan remote '$upRemote' (no more tracking branches)" + git remote remove $upRemote 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Failed to remove remote '$upRemote' (you may remove manually)." } + } else { Info "Remote '$upRemote' retained (other branches still track it)." } + } elseif ($looksForkName -and -not $KeepRemote -and -not $upRemote) { + Warn 'Branch looks like a fork branch (name pattern), but has no upstream remote; nothing to clean.' + } + } + + Info "Removed worktree ($branch) at $folder."; if (-not $KeepBranch) { Info 'Branch deleted.' } + Show-WorktreeExecutionSummary -CurrentBranch $branch +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual cleanup guidelines:' + Info ' git worktree list --porcelain' + Info ' git worktree prune' + Info ' # If still present:' + Info ' Remove-Item -LiteralPath -Recurse -Force' + Info ' git branch -D (if you also want to drop local branch)' + Info ' git remote remove (if orphan fork remote remains)' + Info ' git worktree prune' + exit 1 +} diff --git a/tools/build/New-WorktreeFromBranch.cmd b/tools/build/New-WorktreeFromBranch.cmd new file mode 100644 index 0000000000..a1c2b9a624 --- /dev/null +++ b/tools/build/New-WorktreeFromBranch.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %* diff --git a/tools/build/New-WorktreeFromBranch.ps1 b/tools/build/New-WorktreeFromBranch.ps1 new file mode 100644 index 0000000000..d299e1a879 --- /dev/null +++ b/tools/build/New-WorktreeFromBranch.ps1 @@ -0,0 +1,78 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree for an existing local or remote (origin) branch. + +.DESCRIPTION + Normalizes origin/ to . If the branch does not exist locally (and -NoFetch is not + provided) it will fetch and create a tracking branch from origin. Reuses any existing worktree + bound to the branch; otherwise creates a new one adjacent to the repository root. + +.PARAMETER Branch + Branch name (local or origin/ form) to materialize as a worktree. + +.PARAMETER VSCodeProfile + VS Code profile to open (Default). + +.PARAMETER NoFetch + Skip fetch if branch missing locally; script will error instead of creating it. + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch feature/login + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch origin/bugfix/nullref + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch release/v1 -NoFetch + +.NOTES + Manual recovery: + git fetch origin && git checkout + git worktree add ../RepoName-XX + code ../RepoName-XX --profile Default +#> + +param( + [string] $Branch, + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $NoFetch, + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" + +if ($Help -or -not $Branch) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } + +# Normalize origin/ to +if ($Branch -match '^(origin|upstream|main|master)/.+') { + if ($Branch -match '^(origin|upstream)/(.+)$') { $Branch = $Matches[2] } +} + +try { + git show-ref --verify --quiet "refs/heads/$Branch" + if ($LASTEXITCODE -ne 0) { + if (-not $NoFetch) { + Warn "Local branch '$Branch' not found; attempting remote fetch..." + git fetch --all --prune 2>$null | Out-Null + $remoteRef = "origin/$Branch" + git show-ref --verify --quiet "refs/remotes/$remoteRef" + if ($LASTEXITCODE -eq 0) { + git branch --track $Branch $remoteRef 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create tracking branch '$Branch' from $remoteRef" } + Info "Created local tracking branch '$Branch' from $remoteRef." + } else { throw "Branch '$Branch' not found locally or on origin. Use git fetch or specify a valid branch." } + } else { throw "Branch '$Branch' does not exist locally (remote fetch disabled with -NoFetch)." } + } + + New-WorktreeForExistingBranch -Branch $Branch -VSCodeProfile $VSCodeProfile + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $Branch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $Branch -WorktreePath $path +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info ' git fetch origin' + Info " git checkout $Branch (or: git branch --track $Branch origin/$Branch)" + Info ' git worktree add ../-XX ' + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/New-WorktreeFromFork.cmd b/tools/build/New-WorktreeFromFork.cmd new file mode 100644 index 0000000000..be8bc05c0f --- /dev/null +++ b/tools/build/New-WorktreeFromFork.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %* diff --git a/tools/build/New-WorktreeFromFork.ps1 b/tools/build/New-WorktreeFromFork.ps1 new file mode 100644 index 0000000000..ccd26631e4 --- /dev/null +++ b/tools/build/New-WorktreeFromFork.ps1 @@ -0,0 +1,127 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree from a branch in a personal fork: :. + +.DESCRIPTION + Adds a transient uniquely named fork remote (fork-xxxxx) unless -RemoteName specified. + Fetches only the target branch (fallback full fetch once if needed), creates a local tracking + branch (fork-- or custom alias), and delegates worktree creation/reuse + to shared helpers in WorktreeLib. + +.PARAMETER Spec + Fork spec in the form :. + +.PARAMETER ForkRepo + Repository name in the fork (default: PowerToys). + +.PARAMETER RemoteName + Desired remote name; if left as 'fork' a unique suffix will be generated. + +.PARAMETER BranchAlias + Optional local branch name override; defaults to fork--. + +.PARAMETER VSCodeProfile + VS Code profile to pass through to worktree opening (Default profile by default). + +.EXAMPLE + ./New-WorktreeFromFork.ps1 -Spec alice:feature/new-ui + +.EXAMPLE + ./New-WorktreeFromFork.ps1 -Spec bob:bugfix/crash -BranchAlias fork-bob-crash + +.NOTES + Manual equivalent if this script fails: + git remote add fork-temp https://github.com//.git + git fetch fork-temp + git branch --track fork-- fork-temp/ + git worktree add ../Repo-XX fork-- + code ../Repo-XX +#> +param( + [string] $Spec, + [string] $ForkRepo = 'PowerToys', + [string] $RemoteName = 'fork', + [string] $BranchAlias, + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $Help +) + +. "$PSScriptRoot/WorktreeLib.ps1" +if ($Help -or -not $Spec) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } + +$repoRoot = git rev-parse --show-toplevel 2>$null +if (-not $repoRoot) { throw 'Not inside a git repository.' } + +# Parse spec +if ($Spec -notmatch '^[^:]+:.+$') { throw "Spec must be :, got '$Spec'" } +$ForkUser,$ForkBranch = $Spec.Split(':',2) + +$forkUrl = "https://github.com/$ForkUser/$ForkRepo.git" + +# Auto-suffix remote name if user left default 'fork' +$allRemotes = @(git remote 2>$null) +if ($RemoteName -eq 'fork') { + $chars = 'abcdefghijklmnopqrstuvwxyz0123456789' + do { + $suffix = -join ((1..5) | ForEach-Object { $chars[(Get-Random -Max $chars.Length)] }) + $candidate = "fork-$suffix" + } while ($allRemotes -contains $candidate) + $RemoteName = $candidate + Info "Assigned unique remote name: $RemoteName" +} + +$existing = $allRemotes | Where-Object { $_ -eq $RemoteName } +if (-not $existing) { + Info "Adding remote $RemoteName -> $forkUrl" + git remote add $RemoteName $forkUrl | Out-Null +} else { + $currentUrl = git remote get-url $RemoteName 2>$null + if ($currentUrl -ne $forkUrl) { Warn "Remote $RemoteName points to $currentUrl (expected $forkUrl). Using existing." } +} + +## Note: Verbose fetch & stale lock auto-clean removed for simplicity. + +try { + Info "Fetching branch '$ForkBranch' from $RemoteName..." + & git fetch $RemoteName $ForkBranch 1>$null 2>$null + $fetchExit = $LASTEXITCODE + if ($fetchExit -ne 0) { + # Retry full fetch silently once (covers servers not supporting branch-only fetch syntax) + & git fetch $RemoteName 1>$null 2>$null + $fetchExit = $LASTEXITCODE + } + if ($fetchExit -ne 0) { throw "Fetch failed for remote $RemoteName (branch $ForkBranch)." } + + $remoteRef = "refs/remotes/$RemoteName/$ForkBranch" + git show-ref --verify --quiet $remoteRef + if ($LASTEXITCODE -ne 0) { throw "Remote branch not found: $RemoteName/$ForkBranch" } + + $sanitizedBranch = ($ForkBranch -replace '[\\/:*?"<>|]','-') + if ($BranchAlias) { $localBranch = $BranchAlias } else { $localBranch = "fork-$ForkUser-$sanitizedBranch" } + + git show-ref --verify --quiet "refs/heads/$localBranch" + if ($LASTEXITCODE -ne 0) { + Info "Creating local tracking branch $localBranch from $RemoteName/$ForkBranch" + git branch --track $localBranch "$RemoteName/$ForkBranch" 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create local tracking branch $localBranch" } + } else { Info "Local branch $localBranch already exists." } + + New-WorktreeForExistingBranch -Branch $localBranch -VSCodeProfile $VSCodeProfile + # Ensure upstream so future 'git push' works + Set-BranchUpstream -LocalBranch $localBranch -RemoteName $RemoteName -RemoteBranchPath $ForkBranch + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $localBranch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $localBranch -WorktreePath $path + Warn "Remote $RemoteName ready (URL: $forkUrl)" + $hasUp = git rev-parse --abbrev-ref --symbolic-full-name "$localBranch@{upstream}" 2>$null + if ($hasUp) { Info "Push with: git push (upstream: $hasUp)" } else { Warn 'Upstream not set; run: git push -u :' } +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info " git remote add temp-fork $forkUrl" + Info " git fetch temp-fork" + Info " git branch --track fork-- temp-fork/$ForkBranch" + Info ' git worktree add ../-XX fork--' + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/New-WorktreeFromIssue.cmd b/tools/build/New-WorktreeFromIssue.cmd new file mode 100644 index 0000000000..6aba21652c --- /dev/null +++ b/tools/build/New-WorktreeFromIssue.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %* diff --git a/tools/build/New-WorktreeFromIssue.ps1 b/tools/build/New-WorktreeFromIssue.ps1 new file mode 100644 index 0000000000..c5523fcd13 --- /dev/null +++ b/tools/build/New-WorktreeFromIssue.ps1 @@ -0,0 +1,78 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree for a new issue branch derived from a base ref. + +.DESCRIPTION + Composes a branch name as issue/ or issue/- (slug from optional -Title). + If the branch does not already exist, it is created from -Base (default origin/main). Then a + worktree is created or reused. + +.PARAMETER Number + Issue number used to construct the branch name. + +.PARAMETER Title + Optional descriptive title; slug into the branch name. + +.PARAMETER Base + Base ref to branch from (default origin/main). + +.PARAMETER VSCodeProfile + VS Code profile to open (Default). + +.EXAMPLE + ./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch" + +.EXAMPLE + ./New-WorktreeFromIssue.ps1 -Number 42 -Base origin/develop + +.NOTES + Manual recovery: + git fetch origin + git checkout -b issue/- + git worktree add ../Repo-XX issue/- + code ../Repo-XX +#> + +param( + [int] $Number, + [string] $Title, + [string] $Base = 'origin/main', + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" +$scriptPath = $MyInvocation.MyCommand.Path +if ($Help -or -not $Number) { Show-FileEmbeddedHelp -ScriptPath $scriptPath; return } + +# Compose branch name +if ($Title) { + $slug = ($Title -replace '[^\w\- ]','').ToLower() -replace ' +','-' + $branch = "issue/$Number-$slug" +} else { + $branch = "issue/$Number" +} + +try { + # Create branch if missing + git show-ref --verify --quiet "refs/heads/$branch" + if ($LASTEXITCODE -ne 0) { + Info "Creating branch $branch from $Base" + git branch $branch $Base 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create branch $branch from $Base" } + } else { + Info "Branch $branch already exists locally." + } + + New-WorktreeForExistingBranch -Branch $branch -VSCodeProfile $VSCodeProfile + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $branch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $branch -WorktreePath $path +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info " git fetch origin" + Info " git checkout -b $branch $Base (if branch missing)" + Info " git worktree add ../-XX $branch" + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/Worktree-Guidelines.md b/tools/build/Worktree-Guidelines.md new file mode 100644 index 0000000000..bccd80ab9f --- /dev/null +++ b/tools/build/Worktree-Guidelines.md @@ -0,0 +1,94 @@ +# PowerToys Worktree Helper Scripts + +This folder contains helper scripts to create and manage parallel Git worktree for developing multiple changes (including Copilot suggestions) concurrently without cloning the full repository each time. + +## Why worktree? +Git worktree let you have several checked‑out branches sharing a single `.git` object store. Benefits: +- Fast context switching: no re-clone, no duplicate large binary/object downloads. +- Lower disk usage versus multiple full clones. +- Keeps each change isolated in its own folder so you can run builds/tests independently. +- Enables working in parallel with Copilot generated branches (e.g., feature + quick fix + perf experiment) while the main clone stays clean. + +Recommended: keep active parallel worktree(s) to **≤ 3** per developer to reduce cognitive load and avoid excessive incremental build invalidations. + +## Scripts Overview +| Script | Purpose | +|--------|---------| +| `New-WorktreeFromFork.ps1/.cmd` | Create a worktree from a branch in a personal fork (`:` spec). Adds a temporary unique remote (e.g. `fork-abc12`). | +| `New-WorktreeFromBranch.ps1/.cmd` | Create/reuse a worktree for an existing local or remote (origin) branch. Can normalize `origin/branch` to `branch`. | +| `New-WorktreeFromIssue.ps1/.cmd` | Start a new issue branch from a base (default `origin/main`) using naming `issue/-`. | +| `Delete-Worktree.ps1/.cmd` | Remove a worktree and optionally its local branch / orphan fork remote. | +| `WorktreeLib.ps1` | Shared helpers: unique folder naming, worktree listing, upstream setup, summary output, logging helpers. | + +## Typical Flows +### 1. Create from a fork branch +``` +./New-WorktreeFromFork.ps1 -Spec alice:feature/perf-tweak +``` +Creates remote `fork-xxxxx`, fetches just that branch, creates local branch `fork-alice-feature-perf-tweak`, makes a new worktree beside the repo root. + +### 2. Create from an existing or remote branch +``` +./New-WorktreeFromBranch.ps1 -Branch origin/feature/new-ui +``` +Fetches if needed and creates a tracking branch if missing, then creates/reuses the worktree. + +### 3. Start a new issue branch +``` +./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch" +``` +Creates branch `issue/1234-crash-on-launch` off `origin/main` (or `-Base`), then worktree. + +### 4. Delete a worktree when done +``` +./Delete-Worktree.ps1 -Pattern feature/perf-tweak +``` +If only one match, removes the worktree directory. Add `-Force` to discard local changes. Use `-KeepBranch` if you still need the branch, `-KeepRemote` to retain a fork remote. + +## After Creating a Worktree +Inside the new worktree directory: +1. Run the minimal build bootstrap in VSCode terminal: +``` +tools\build\build-essentials.cmd +``` +2. Build only the module(s) you need (e.g., open solution filter or run targeted project build) instead of a full PowerToys build. This speeds iteration and reduces noise. +3. Make changes, commit, push. +4. Finally delete the worktree when done. + +## Naming & Locations +- Worktree is created as sibling folders of the repo root (e.g., `PowerToys` + `PowerToys-ab12`), using a hash/short pattern to avoid collisions. +- Fork-based branches get local names `fork--`. +- Issue branches: `issue/` or `issue/-`. + +## Scenarios Covered / Limitations +Covered scenarios: +1. From a fork branch (personal fork on GitHub). +2. From an existing local or origin remote branch. +3. Creating a new branch for an issue. + +Not covered (manual steps needed): +- Creating from a non-origin upstream other than a fork (add remote manually then use branch script). +- Batch creation of multiple worktree in one command. +- Automatic rebase / sync of many worktree at once (do that manually or script separately). + +## Best Practices +- Keep ≤ 3 active parallel worktree(s) (e.g., main dev, a long-lived feature, a quick fix / experiment) plus the root clone. +- Delete stale worktree early; each adds file watchers & potential incremental build churn. +- Avoid editing the same file across multiple worktree simultaneously to reduce merge friction. +- Run `git fetch --all --prune` periodically in the primary repo, not in every worktree. + +## Troubleshooting +| Symptom | Hint | +|---------|------| +| Fetch failed for fork remote | Branch name typo or fork private without auth. Try manual `git fetch `. +| Cannot lock ref *.lock | Stale lock: run `git worktree prune` or manually delete the `.lock` file then retry. +| Worktree already exists error | Use `git worktree list` to locate existing path; open that folder instead of creating a duplicate. +| Local branch missing for remote | Use `git branch --track origin/` then re-run the branch script. + +## Security & Safety Notes +- Scripts avoid force-deleting unless you pass `-Force` (Delete script). +- No network credentials are stored; they rely on your existing Git credential helper. +- Always review a new fork remote URL before pushing. + +--- +Maintainers: Keep the scripts lean; avoid adding heavy dependencies or global state. Update this doc if parameters or flows change. diff --git a/tools/build/WorktreeLib.ps1 b/tools/build/WorktreeLib.ps1 new file mode 100644 index 0000000000..01883115d1 --- /dev/null +++ b/tools/build/WorktreeLib.ps1 @@ -0,0 +1,151 @@ +# WorktreeLib.ps1 - shared helpers + +function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan } +function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow } +function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red } + +function Get-RepoRoot { + $root = git rev-parse --show-toplevel 2>$null + if (-not $root) { throw 'Not inside a git repository.' } + return $root +} + +function Get-WorktreeBasePath { + param([string]$RepoRoot) + # Always use parent of repo root (folder that contains the main repo directory) + $parent = Split-Path -Parent $RepoRoot + if (-not (Test-Path $parent)) { throw "Parent path for repo root not found: $parent" } + return (Resolve-Path $parent).ProviderPath +} + +function Get-ShortHashFromString { + param([Parameter(Mandatory)][string]$Text) + $md5 = [System.Security.Cryptography.MD5]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + $digest = $md5.ComputeHash($bytes) + return -join ($digest[0..1] | ForEach-Object { $_.ToString('x2') }) + } finally { $md5.Dispose() } +} + +function Initialize-SubmodulesIfAny { + param([string]$RepoRoot,[string]$WorktreePath) + $hasGitmodules = Test-Path (Join-Path $RepoRoot '.gitmodules') + if ($hasGitmodules) { + git -C $WorktreePath submodule sync --recursive | Out-Null + git -C $WorktreePath submodule update --init --recursive | Out-Null + return $true + } + return $false +} + +function New-WorktreeForExistingBranch { + param( + [Parameter(Mandatory)][string] $Branch, + [Parameter(Mandatory)][string] $VSCodeProfile + ) + $repoRoot = Get-RepoRoot + git show-ref --verify --quiet "refs/heads/$Branch"; if ($LASTEXITCODE -ne 0) { throw "Branch '$Branch' does not exist locally." } + + # Detect existing worktree for this branch + $entries = Get-WorktreeEntries + $match = $entries | Where-Object { $_.Branch -eq $Branch } | Select-Object -First 1 + if ($match) { + Info "Reusing existing worktree for '$Branch': $($match.Path)" + code --new-window "$($match.Path)" --profile "$VSCodeProfile" | Out-Null + return + } + + $safeBranch = ($Branch -replace '[\\/:*?"<>|]','-') + $hash = Get-ShortHashFromString -Text $safeBranch + $folderName = "$(Split-Path -Leaf $repoRoot)-$hash" + $base = Get-WorktreeBasePath -RepoRoot $repoRoot + $folder = Join-Path $base $folderName + git worktree add $folder $Branch + $inited = Initialize-SubmodulesIfAny -RepoRoot $repoRoot -WorktreePath $folder + code --new-window "$folder" --profile "$VSCodeProfile" | Out-Null + Info "Created worktree for branch '$Branch' at $folder."; if ($inited) { Info 'Submodules initialized.' } +} + +function Get-WorktreeEntries { + # Returns objects with Path and Branch (branch without refs/heads/ prefix) + $lines = git worktree list --porcelain 2>$null + if (-not $lines) { return @() } + $entries = @(); $current=@{} + foreach($l in $lines){ + if ($l -eq '') { if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }; $current=@{}; continue } + if ($l -like 'worktree *'){ $current.path = ($l -split ' ',2)[1] } + elseif ($l -like 'branch *'){ $current.branch = ($l -split ' ',2)[1].Trim() } + } + if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) } + return ($entries | Sort-Object Path,Branch -Unique) +} + +function Get-BranchUpstreamRemote { + param([Parameter(Mandatory)][string]$Branch) + # Returns remote name if branch has an upstream, else $null + $ref = git rev-parse --abbrev-ref --symbolic-full-name "$Branch@{upstream}" 2>$null + if ($LASTEXITCODE -ne 0 -or -not $ref) { return $null } + if ($ref -match '^(?[^/]+)/.+$') { return $Matches.remote } + return $null +} + +function Show-IssueFarmCommonFooter { + Info '--- Common Manual Steps ---' + Info 'List worktree: git worktree list --porcelain' + Info 'List branches: git branch -vv' + Info 'List remotes: git remote -v' + Info 'Prune worktree: git worktree prune' + Info 'Remove worktree dir: Remove-Item -Recurse -Force ' + Info 'Reset branch: git reset --hard HEAD' +} + +function Show-WorktreeExecutionSummary { + param( + [string]$CurrentBranch, + [string]$WorktreePath + ) + Info '--- Summary ---' + if ($CurrentBranch) { Info "Branch: $CurrentBranch" } + if ($WorktreePath) { Info "Worktree path: $WorktreePath" } + $entries = Get-WorktreeEntries + if ($entries.Count -gt 0) { + Info 'Existing worktrees:' + $entries | ForEach-Object { Info (" {0} -> {1}" -f $_.Branch,$_.Path) } + } + Info 'Remotes:' + git remote -v 2>$null | Sort-Object | Get-Unique | ForEach-Object { Info " $_" } +} + +function Show-FileEmbeddedHelp { + param([string]$ScriptPath) + if (-not (Test-Path $ScriptPath)) { throw "Cannot load help; file missing: $ScriptPath" } + $content = Get-Content -LiteralPath $ScriptPath -ErrorAction Stop + $inBlock=$false + foreach($line in $content){ + if ($line -match '^<#!') { $inBlock=$true; continue } + if ($line -match '#>$') { break } + if ($inBlock) { Write-Host $line } + } + Show-IssueFarmCommonFooter +} + +function Set-BranchUpstream { + param( + [Parameter(Mandatory)][string]$LocalBranch, + [Parameter(Mandatory)][string]$RemoteName, + [Parameter(Mandatory)][string]$RemoteBranchPath + ) + $current = git rev-parse --abbrev-ref --symbolic-full-name "$LocalBranch@{upstream}" 2>$null + if (-not $current) { + Info "Setting upstream: $LocalBranch -> $RemoteName/$RemoteBranchPath" + git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Failed to set upstream automatically. Run: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } + return + } + if ($current -ne "$RemoteName/$RemoteBranchPath") { + Warn "Upstream mismatch ($current != $RemoteName/$RemoteBranchPath); updating..." + git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Could not update upstream; manual fix: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } else { Info 'Upstream corrected.' } + } else { Info "Upstream already: $current" } +} diff --git a/tools/build/build-installer.ps1 b/tools/build/build-installer.ps1 index 2229be63ae..83a47f2092 100644 --- a/tools/build/build-installer.ps1 +++ b/tools/build/build-installer.ps1 @@ -21,12 +21,9 @@ Specifies the build configuration (e.g., 'Debug', 'Release'). Default is 'Releas .PARAMETER PerUser Specifies whether to build a per-user installer (true) or machine-wide installer (false). Default is true (per-user). -.PARAMETER InstallerSuffix -Specifies the suffix for the installer naming (e.g., 'wix5', 'vnext'). Default is 'wix5'. - .EXAMPLE .\build-installer.ps1 -Runs the installer build pipeline for x64 Release with default suffix (wix5). +Runs the installer build pipeline for x64 Release. .EXAMPLE .\build-installer.ps1 -Platform x64 -Configuration Release @@ -36,10 +33,6 @@ Runs the pipeline for x64 Release. .\build-installer.ps1 -Platform x64 -Configuration Release -PerUser false Runs the pipeline for x64 Release with machine-wide installer. -.EXAMPLE -.\build-installer.ps1 -Platform x64 -Configuration Release -InstallerSuffix vnext -Runs the pipeline for x64 Release with 'vnext' suffix. - .NOTES - Make sure to run this script from a Developer PowerShell (e.g., VS2022 Developer PowerShell). - Generated MSIX files will be signed using cert-sign-package.ps1. @@ -54,8 +47,7 @@ Runs the pipeline for x64 Release with 'vnext' suffix. param ( [string]$Platform = '', [string]$Configuration = 'Release', - [string]$PerUser = 'true', - [string]$InstallerSuffix = 'wix5' + [string]$PerUser = 'true' ) # Ensure helpers are available @@ -97,7 +89,7 @@ if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot "PowerToys.sln"))) { } Write-Host "PowerToys repository root detected: $repoRoot" -# WiX v5 projects use WixToolset.Sdk via NuGet/MSBuild; a separate WiX 3 installation is not required here. +# WiX v5 projects use WixToolset.Sdk via NuGet/MSBuild; no separate WiX installation is required. Write-Host ("[PIPELINE] Start | Platform={0} Configuration={1} PerUser={2}" -f $Platform, $Configuration, $PerUser) Write-Host '' @@ -124,6 +116,20 @@ else { Write-Warning "[SIGN] No .msix files found in $msixSearchRoot" } +# Generate DSC manifest files +Write-Host '[DSC] Generating DSC manifest files...' +$dscScriptPath = Join-Path $repoRoot '.\tools\build\generate-dsc-manifests.ps1' +if (Test-Path $dscScriptPath) { + & $dscScriptPath -BuildPlatform $Platform -BuildConfiguration $Configuration -RepoRoot $repoRoot + if ($LASTEXITCODE -ne 0) { + Write-Error "DSC manifest generation failed with exit code $LASTEXITCODE" + exit 1 + } + Write-Host '[DSC] DSC manifest files generated successfully' +} else { + Write-Warning "[DSC] DSC manifest generator script not found at: $dscScriptPath" +} + RestoreThenBuild 'tools\BugReportTool\BugReportTool.sln' $commonArgs $Platform $Configuration RestoreThenBuild 'tools\StylesReportTool\StylesReportTool.sln' $commonArgs $Platform $Configuration @@ -137,8 +143,8 @@ try { RunMSBuild 'installer\PowerToysSetup.sln' "$commonArgs /t:restore /p:RestorePackagesConfig=true" $Platform $Configuration -RunMSBuild 'installer\PowerToysSetup.sln' "$commonArgs /m /t:PowerToysInstallerVNext /p:PerUser=$PerUser /p:InstallerSuffix=$InstallerSuffix" $Platform $Configuration +RunMSBuild 'installer\PowerToysSetup.sln' "$commonArgs /m /t:PowerToysInstallerVNext /p:PerUser=$PerUser" $Platform $Configuration -RunMSBuild 'installer\PowerToysSetup.sln' "$commonArgs /m /t:PowerToysBootstrapperVNext /p:PerUser=$PerUser /p:InstallerSuffix=$InstallerSuffix" $Platform $Configuration +RunMSBuild 'installer\PowerToysSetup.sln' "$commonArgs /m /t:PowerToysBootstrapperVNext /p:PerUser=$PerUser" $Platform $Configuration Write-Host '[PIPELINE] Completed' diff --git a/tools/build/ensure-wix.ps1 b/tools/build/ensure-wix.ps1 deleted file mode 100644 index 988d382f07..0000000000 --- a/tools/build/ensure-wix.ps1 +++ /dev/null @@ -1,71 +0,0 @@ -<# -.SYNOPSIS - Ensure WiX Toolset 3.14 (build 3141) is installed and ready to use. - -.DESCRIPTION - - Skips installation if the toolset is already installed (unless -Force is used). - - Otherwise downloads the official installer and binaries, verifies SHA-256, installs silently, - and copies wix.targets into the installation directory. -.PARAMETER Force - Forces reinstallation even if the toolset is already detected. -.PARAMETER InstallDir - The target installation path. Default is 'C:\Program Files (x86)\WiX Toolset v3.14'. -.EXAMPLE - .\EnsureWix.ps1 # Ensure WiX is installed - .\EnsureWix.ps1 -Force # Force reinstall -#> -[CmdletBinding()] -param( - [switch]$Force, - [string]$InstallDir = 'C:\Program Files (x86)\WiX Toolset v3.14' -) - -$ErrorActionPreference = 'Stop' -$ProgressPreference = 'SilentlyContinue' - -# Download URLs and expected SHA-256 hashes -$WixDownloadUrl = 'https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe' -$WixBinariesDownloadUrl = 'https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip' -$InstallerHashExpected = '6BF6D03D6923D9EF827AE1D943B90B42B8EBB1B0F68EF6D55F868FA34C738A29' -$BinariesHashExpected = '6AC824E1642D6F7277D0ED7EA09411A508F6116BA6FAE0AA5F2C7DAA2FF43D31' - -# Check if WiX is already installed -$candlePath = Join-Path $InstallDir 'bin\candle.exe' -if (-not $Force -and (Test-Path $candlePath)) { - Write-Host "WiX Toolset is already installed at `"$InstallDir`". Skipping installation." - return -} - -# Temp file paths -$tmpDir = [IO.Path]::GetTempPath() -$installer = Join-Path $tmpDir 'wix314.exe' -$binariesZip = Join-Path $tmpDir 'wix314-binaries.zip' - -# Download installer and binaries -Write-Host 'Downloading WiX installer...' -Invoke-WebRequest -Uri $WixDownloadUrl -OutFile $installer -UseBasicParsing -Write-Host 'Downloading WiX binaries...' -Invoke-WebRequest -Uri $WixBinariesDownloadUrl -OutFile $binariesZip -UseBasicParsing - -# Verify SHA-256 hashes -Write-Host 'Verifying installer hash...' -if ((Get-FileHash -Algorithm SHA256 $installer).Hash -ne $InstallerHashExpected) { - throw 'wix314.exe SHA256 hash mismatch' -} -Write-Host 'Verifying binaries hash...' -if ((Get-FileHash -Algorithm SHA256 $binariesZip).Hash -ne $BinariesHashExpected) { - throw 'wix314-binaries.zip SHA256 hash mismatch' -} - -# Perform silent installation -Write-Host 'Installing WiX Toolset silently...' -Start-Process -FilePath $installer -ArgumentList '/install','/quiet' -Wait - -# Extract binaries and copy wix.targets -$expandDir = Join-Path $tmpDir 'wix-binaries' -if (Test-Path $expandDir) { Remove-Item $expandDir -Recurse -Force } -Expand-Archive -Path $binariesZip -DestinationPath $expandDir -Force -Copy-Item -Path (Join-Path $expandDir 'wix.targets') ` - -Destination (Join-Path $InstallDir 'wix.targets') -Force - -Write-Host "WiX Toolset has been successfully installed at: $InstallDir" diff --git a/tools/build/generate-dsc-manifests.ps1 b/tools/build/generate-dsc-manifests.ps1 new file mode 100644 index 0000000000..78cc909174 --- /dev/null +++ b/tools/build/generate-dsc-manifests.ps1 @@ -0,0 +1,123 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BuildPlatform, + + [Parameter(Mandatory = $true)] + [string]$BuildConfiguration, + + [Parameter()] + [string]$RepoRoot = (Get-Location).Path, + + [switch]$ForceRebuildExecutable +) + +$ErrorActionPreference = 'Stop' + +function Resolve-PlatformDirectory { + param( + [string]$Root, + [string]$Platform + ) + + $normalized = $Platform.Trim() + $candidates = @() + $candidates += Join-Path $Root $normalized + $candidates += Join-Path $Root ($normalized.ToUpperInvariant()) + $candidates += Join-Path $Root ($normalized.ToLowerInvariant()) + $candidates = $candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique + + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + return $candidates[0] +} + +Write-Host "Repo root: $RepoRoot" +Write-Host "Requested build platform: $BuildPlatform" +Write-Host "Requested configuration: $BuildConfiguration" + +# Always use x64 PowerToys.DSC.exe since CI/CD machines are x64 +$exePlatform = 'x64' +$exeRoot = Resolve-PlatformDirectory -Root $RepoRoot -Platform $exePlatform +$exeOutputDir = Join-Path $exeRoot $BuildConfiguration +$exePath = Join-Path $exeOutputDir 'PowerToys.DSC.exe' + +Write-Host "Using x64 PowerToys.DSC.exe to generate DSC manifests for $BuildPlatform build" + +if ($ForceRebuildExecutable -or -not (Test-Path $exePath)) { + Write-Host "PowerToys.DSC.exe not found at '$exePath'. Building x64 binary..." + + $msbuild = Get-Command msbuild.exe -ErrorAction SilentlyContinue + if ($null -eq $msbuild) { + throw "msbuild.exe was not found on the PATH." + } + + $projectPath = Join-Path $RepoRoot 'src\dsc\v3\PowerToys.DSC\PowerToys.DSC.csproj' + $msbuildArgs = @( + $projectPath, + '/t:Build', + '/m', + "/p:Configuration=$BuildConfiguration", + "/p:Platform=x64", + '/restore' + ) + + & $msbuild.Path @msbuildArgs + $msbuildExitCode = $LASTEXITCODE + + if ($msbuildExitCode -ne 0) { + throw "msbuild build failed with exit code $msbuildExitCode" + } + + if (-not (Test-Path $exePath)) { + throw "Expected PowerToys.DSC.exe at '$exePath' after build but it was not found." + } +} else { + Write-Host "Using existing PowerToys.DSC.exe at '$exePath'." +} + +# Output DSC manifests to the target build platform directory (x64, ARM64, etc.) +$outputRoot = Resolve-PlatformDirectory -Root $RepoRoot -Platform $BuildPlatform +if (-not (Test-Path $outputRoot)) { + Write-Host "Creating missing platform output root at '$outputRoot'." + New-Item -Path $outputRoot -ItemType Directory -Force | Out-Null +} + +$outputDir = Join-Path $outputRoot $BuildConfiguration +if (-not (Test-Path $outputDir)) { + Write-Host "Creating missing configuration output directory at '$outputDir'." + New-Item -Path $outputDir -ItemType Directory -Force | Out-Null +} + +# DSC v3 manifests go to DSCModules subfolder +$dscOutputDir = Join-Path $outputDir 'DSCModules' +if (-not (Test-Path $dscOutputDir)) { + Write-Host "Creating DSCModules subfolder at '$dscOutputDir'." + New-Item -Path $dscOutputDir -ItemType Directory -Force | Out-Null +} + +Write-Host "DSC manifests will be generated to: '$dscOutputDir'" + +Write-Host "Cleaning previously generated DSC manifest files from '$dscOutputDir'." +Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction SilentlyContinue | Remove-Item -Force + +$arguments = @('manifest', '--resource', 'settings', '--outputDir', $dscOutputDir) +Write-Host "Invoking DSC manifest generator: '$exePath' $($arguments -join ' ')" +& $exePath @arguments +if ($LASTEXITCODE -ne 0) { + throw "PowerToys.DSC.exe exited with code $LASTEXITCODE" +} + +$generatedFiles = Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction Stop +if ($generatedFiles.Count -eq 0) { + throw "No DSC manifest files were generated in '$dscOutputDir'." +} + +Write-Host "Generated $($generatedFiles.Count) DSC manifest file(s):" +foreach ($file in $generatedFiles) { + Write-Host " - $($file.FullName)" +}