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..c6f1225788 100644 --- a/.github/actions/spell-check/excludes.txt +++ b/.github/actions/spell-check/excludes.txt @@ -121,6 +121,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 +135,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 c4c13282e4..c89141a8c5 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 @@ -25,8 +26,7 @@ ADMINS adml admx advancedpaste -advancedpasteui -advancedpasteuishortcut +advapi advfirewall AFeature affordances @@ -43,7 +43,6 @@ ALLINPUT Allman Allmodule ALLOWUNDO -allpc ALLVIEW ALPHATYPE AModifier @@ -76,6 +75,7 @@ appwiz appxpackage APSTUDIO AQS +Aquadrant ARandom ARCHITEW ARemapped @@ -133,7 +133,6 @@ bla BLACKFRAME BLENDFUNCTION Blockquotes -blogs Blt BLURBEHIND BLURREGION @@ -159,6 +158,7 @@ BUILDARCH BUILDNUMBER buildtransitive builttoroam +BUNDLEINFO BVal BValue byapp @@ -364,6 +364,7 @@ desktopshorcutinstalled DESKTOPVERTRES devblogs devdocs +devenv devmgmt DEVMODE DEVMODEW @@ -392,6 +393,7 @@ DNLEN DONOTROUND DONTVALIDATEPATH dotnet +downscale DPICHANGED DPIs DPSAPI @@ -451,6 +453,7 @@ encryptor ENDSESSION ENSUREVISIBLE ENTERSIZEMOVE +ENTRYW ENU environmentvariables EOAC @@ -481,6 +484,7 @@ examplehandler examplepowertoy EXAND EXCLUDEFROMCAPTURE +EXECUTEDEFAULT executionpolicy exename exf @@ -504,7 +508,6 @@ FANCYZONESDRAWLAYOUTTEST FANCYZONESEDITOR FARPROC fesf -fff FFFF FILEEXPLORER fileexploreraddons @@ -567,6 +570,7 @@ GETDESKWALLPAPER GETDLGCODE GETDPISCALEDSIZE getfilesiginforedist +geolocator GETHOTKEY GETICON GETMINMAXINFO @@ -663,11 +667,7 @@ Hostx hotfixes hotkeycontrol HOTKEYF -hotkeylockmachine -hotkeyreconnect hotkeys -hotkeyswitch -hotkeytoggleeasymouse hotlight hotspot HPAINTBUFFER @@ -726,8 +726,6 @@ IMAGERESIZERCONTEXTMENU IMAGERESIZEREXT imageresizerinput imageresizersettings -imagetotext -imagetotextshortcut imagingdevices ime imgflip @@ -818,10 +816,12 @@ killrunner kmph kvp Kybd +LARGEICON lastcodeanalysissucceeded LASTEXITCODE LAYOUTRTL LCh +lbl lcid LCIDTo lcl @@ -840,10 +840,12 @@ LIBID LIMITSIZE LIMITTEXT lindex +lightswitch linkid LINKOVERLAY LINQTo listview +LIVEDRAW LIVEZOOM LLKH llkhf @@ -855,17 +857,19 @@ localappdata localpackage LOCALSYSTEM LOCATIONCHANGE -LOCKMACHINE LOCKTYPE LOGFONT LOGFONTW logon +LOGMSG LOGPIXELSX LOGPIXELSY -LOn +lng +lon longdate LONGNAMES lowlevel +lquadrant LOWORD lparam LPBITMAPINFOHEADER @@ -941,7 +945,6 @@ MDL mdtext mdtxt mdwn -measuretool meme memicmp MENUITEMINFO @@ -991,10 +994,12 @@ MOUSEHWHEEL MOUSEINPUT mousejump mousepointer -mousepointercrosshairs mouseutils MOVESIZEEND MOVESIZESTART +muxx +muxxc +muxxh MRM MRT mru @@ -1189,23 +1194,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 @@ -1213,6 +1208,7 @@ PATPAINT pbc pbi PBlob +pbrush pcb pcch pcelt @@ -1246,6 +1242,7 @@ pgp pguid phbm phbmp +phicon phwnd pici pidl @@ -1254,6 +1251,7 @@ pinfo pinvoke pipename PKBDLLHOOKSTRUCT +pkgfamily plib ploc ploca @@ -1273,7 +1271,6 @@ Pomodoro Popups POPUPWINDOW POSITIONITEM -powerocr POWERRENAMECONTEXTMENU powerrenameinput POWERRENAMETEST @@ -1324,7 +1321,6 @@ PRODUCTVERSION Progman programdata projectname -projitems PROPERTYKEY Propset PROPVARIANT @@ -1372,8 +1368,9 @@ quickaccent QUNS RAII RAlt +RAquadrant randi -Rasterization +rasterization Rasterize RAWINPUTDEVICE RAWINPUTHEADER @@ -1416,7 +1413,6 @@ Removelnk renamable RENAMEONCOLLISION reparented -reparenthotkey reparenting reportfileaccesses requery @@ -1442,7 +1438,6 @@ RIDEV RIGHTSCROLLBAR riid RKey -Rns RNumber rop ROUNDSMALL @@ -1666,10 +1661,10 @@ STYLECHANGED STYLECHANGING subkeys sublang -Subdomain SUBMODULEUPDATE subresource Superbar +suntimes sut svchost SVGIn @@ -1735,9 +1730,9 @@ tgz themeresources THH THICKFRAME +THEMECHANGED THISCOMPONENT throughs -thumbnailhotkey TILEDWINDOW TILLSON timedate @@ -1751,10 +1746,9 @@ tkconverters tlb tlbimp tlc +tmain TNP -TOGGLEEASYMOUSE Toolhelp -toolkitconverters toolwindow TOPDOWNDIB TOUCHEVENTF @@ -1766,11 +1760,9 @@ tracelogging tracerpt trackbar trafficmanager -transcodetomp transicc TRAYMOUSEMESSAGE triaging -Tru trl trx tsa diff --git a/.github/actions/spell-check/patterns.txt b/.github/actions/spell-check/patterns.txt index 255a7f984c..cb303a10ad 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 @@ -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/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/.gitignore b/.gitignore index 8859e53742..ed3f80a4ec 100644 --- a/.gitignore +++ b/.gitignore @@ -350,7 +350,9 @@ src/common/Telemetry/*.etl # Generated installer file for Monaco source files. /installer/PowerToysSetup/MonacoSRC.wxs +/installer/PowerToysSetup/DscResources.wxs /installer/PowerToysSetupVNext/MonacoSRC.wxs +/installer/PowerToysSetupVNext/DscResources.wxs # MSBuildCache /MSBuildCacheLogs/ diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 9cb1fcb7d5..2a1760d94e 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -55,7 +55,6 @@ "PowerToys.Awake.exe", "PowerToys.Awake.dll", - "PowerToys.FancyZonesEditor.exe", "PowerToys.FancyZonesEditor.dll", "PowerToys.FancyZonesEditorCommon.dll", @@ -134,6 +133,9 @@ "PowerToys.ImageResizerContextMenu.dll", "ImageResizerContextMenuPackage.msix", + "PowerToys.LightSwitchModuleInterface.dll", + "LightSwitchService\\PowerToys.LightSwitchService.exe", + "PowerToys.KeyboardManager.dll", "KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe", "KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe", @@ -230,7 +232,10 @@ "PowerToys.CmdPalModuleInterface.dll", "CmdPalKeyboardService.dll", - "*Microsoft.CmdPal.UI_*.msix" + "*Microsoft.CmdPal.UI_*.msix", + + "PowerToys.DSC.dll", + "PowerToys.DSC.exe" ], "SigningInfo": { "Operations": [ @@ -297,6 +302,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", @@ -322,6 +330,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", diff --git a/.pipelines/generateDscManifests.ps1 b/.pipelines/generateDscManifests.ps1 new file mode 100644 index 0000000000..109610e62e --- /dev/null +++ b/.pipelines/generateDscManifests.ps1 @@ -0,0 +1,88 @@ +[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 +} + +Write-Host "DSC manifests will be generated to: '$outputDir'" + +Write-Host "Cleaning previously generated DSC manifest files from '$outputDir'." +Get-ChildItem -Path $outputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction SilentlyContinue | Remove-Item -Force + +$arguments = @('manifest', '--resource', 'settings', '--outputDir', $outputDir) +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 $outputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction Stop +if ($generatedFiles.Count -eq 0) { + throw "No DSC manifest files were generated in '$outputDir'." +} + +Write-Host "Generated $($generatedFiles.Count) DSC manifest file(s):" +foreach ($file in $generatedFiles) { + Write-Host " - $($file.FullName)" +} 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/release.yml b/.pipelines/v2/release.yml index e13792d8d1..45514d4b0f 100644 --- a/.pipelines/v2/release.yml +++ b/.pipelines/v2/release.yml @@ -43,11 +43,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: @@ -109,8 +104,8 @@ 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:InstallerSuffix=${{ parameters.installerSuffix }} /p:EnableCmdPalAOT=true beforeBuildSteps: # Sets versions for all PowerToy created DLLs - pwsh: |- diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index 5c8fb29a75..6994c7a199 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 @@ -154,6 +157,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 @@ -263,6 +271,23 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + # 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: 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: @@ -418,7 +443,7 @@ jobs: } if ($Packages.Count -gt 0) { - # Priority: Look for platform-specific MSIX (x64/arm64) first, then fallback to any + # 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 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..6db52ec631 100644 --- a/.pipelines/v2/templates/steps-build-installer-vnext.yml +++ b/.pipelines/v2/templates/steps-build-installer-vnext.yml @@ -132,6 +132,39 @@ steps: ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' #### END MSI + + #### BUILDING AND SIGNING SilentFilesInUseBAFunction DLL + - task: VSBuild@1 + displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build SilentFilesInUseBAFunction + inputs: + solution: "**/installer/PowerToysSetup.sln" + vsVersion: 17.0 + msbuildArgs: >- + /t:SilentFilesInUseBAFunction + /p:RunBuildEvents=true;PerUser=${{parameters.buildUserInstaller}};RestorePackagesConfig=true;CIBuild=true + /p:InstallerSuffix=${{ parameters.installerSuffix }} + -restore -graph + /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-SilentFilesInUseBAFunction.binlog + ${{ parameters.additionalBuildOptions }} + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + clean: false # don't undo our hard work above by deleting the msi + msbuildArchitecture: x64 + maximumCpuCount: true + + - ${{ if eq(parameters.codeSign, true) }}: + - template: steps-esrp-signing.yml + parameters: + displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign SilentFilesInUseBAFunction + signingIdentity: ${{ parameters.signingIdentity }} + inputs: + FolderPath: 'installer/$(BuildPlatform)/$(BuildConfiguration)' + signType: batchSigning + batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' + ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' + + #### END BUILDING AND SIGNING SilentFilesInUseBAFunction DLL + #### BOOTSTRAP BUILDING AND SIGNING - task: VSBuild@1 displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build VNext Bootstrapper @@ -148,7 +181,7 @@ steps: ${{ 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 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/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 e3688d6614..9ce4168538 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,7 @@  true + true @@ -21,11 +22,11 @@ - - + + @@ -37,6 +38,7 @@ + @@ -66,6 +68,7 @@ + @@ -106,7 +109,7 @@ - + diff --git a/NOTICE.md b/NOTICE.md index bedc11379d..1998ea805a 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -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 a72d31b13a..50063816ea 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} @@ -638,7 +640,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 +730,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 +795,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 +819,12 @@ 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("{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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -2883,6 +2903,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 +2927,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 +2975,26 @@ 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 + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3160,7 +3232,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} @@ -3236,12 +3308,21 @@ 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} + {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/README.md b/README.md index cf2dd7beba..631b43d6aa 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,56 @@ -# Microsoft PowerToys +

+ + + + +

+

+ Microsoft PowerToys +

-![Hero image for Microsoft PowerToys](doc/images/overview/PT_hero_image.png) +

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

+

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

-[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) +| | | | +|---|---|---| +| [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) | [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) | -## About -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]! +## 📋 Installation -| | 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) | +For detailed installation instructions, visit the [installation docs](https://learn.microsoft.com/windows/powertoys/install). -## Installing and running Microsoft PowerToys +Before you begin, make sure your device meets the system requirements: -### Requirements +> [!NOTE] +> - Windows 11 or Windows 10 version 2004 (20H1 / build 19041) or newer +> - 64-bit processor: x64 or ARM64 +> - Latest stable version of [Microsoft Edge WebView2 Runtime](https://go.microsoft.com/fwlink/p/?LinkId=2124703) is installed via the bootstrapper during setup -- 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. +Choose one of the installation methods below: -### Via GitHub with EXE [Recommended] +
+Download .exe from GitHub -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. +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 @@ -49,57 +67,49 @@ Go to the [Microsoft PowerToys GitHub releases page][github-release-link] and cl | Machine wide - x64 | [PowerToysSetup-0.94.0-x64.exe][ptMachineX64] | | Machine wide - ARM64 | [PowerToysSetup-0.94.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.94 (September 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 - -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. - -## 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** +**✨ 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. @@ -138,13 +148,13 @@ For an in-depth look at the latest changes, visit the [release blog](https://aka - 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. + - 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. + - 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. @@ -220,10 +230,10 @@ For an in-depth look at the latest changes, visit the [release blog](https://aka - 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. + - Cleans up AI-generated tests; adds meaningful query tests across extensions. - Removed the obsolete debug dialog from Settings for a smoother developer loop. -### What is being planned over the next few releases +## 🛣️ Roadmap For [v0.95][github-next-release-work], we'll work on the items below: @@ -235,9 +245,19 @@ For [v0.95][github-next-release-work], we'll work on the items below: - New UI automation tests - Stability, bug fixes -## PowerToys Community +## ❤️ 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. Month by month, you directly help make PowerToys a better piece of software. +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! + +## 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. ## Code of Conduct 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/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/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/PowerToysInstaller.wixproj b/installer/PowerToysSetup/PowerToysInstaller.wixproj index 6a28fbc896..4a391eb901 100644 --- a/installer/PowerToysSetup/PowerToysInstaller.wixproj +++ b/installer/PowerToysSetup/PowerToysInstaller.wixproj @@ -16,6 +16,7 @@ SET PTRoot=$(SolutionDir)\.. call "..\..\..\publish.cmd" x64 ) call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" +call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateDscResourcesWxs.ps1 -dscWxsFile "$(MSBuildThisFileDirectory)\DscResources.wxs" -Platform "$(Platform)" -Configuration "$(Configuration)" @@ -26,6 +27,7 @@ SET PTRoot=$(SolutionDir)\.. call "..\..\..\publish.cmd" arm64 ) call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" +call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateDscResourcesWxs.ps1 -dscWxsFile "$(MSBuildThisFileDirectory)\DscResources.wxs" -Platform "$(Platform)" -Configuration "$(Configuration)" @@ -121,6 +123,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil + diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs index 77ffad8483..c7f9d3bda4 100644 --- a/installer/PowerToysSetup/Product.wxs +++ b/installer/PowerToysSetup/Product.wxs @@ -75,6 +75,7 @@ + @@ -324,7 +325,6 @@ BinaryKey="PTCustomActions" DllEntry="UninstallDSCModuleCA" /> - + + + + + + +"@ + Set-Content -Path $dscWxsFile -Value $wxsContent + exit 0 +} + +Write-Host "Found $($dscFiles.Count) DSC manifest file(s)" + +$wxsContent = @" + + + + +"@ + +$componentRefs = @() +foreach ($file in $dscFiles) { + $componentId = "DscResource_" + ($file.BaseName -replace '[^A-Za-z0-9_]', '_') + $fileId = $componentId + "_File" + $guid = [System.Guid]::NewGuid().ToString().ToUpper() + $componentRefs += $componentId + + $wxsContent += @" + + + + + + + +"@ +} + +$wxsContent += @" + + + + + +"@ + +foreach ($componentId in $componentRefs) { + $wxsContent += @" + + +"@ +} + +$wxsContent += @" + + + + +"@ + +Set-Content -Path $dscWxsFile -Value $wxsContent +Write-Host "Generated DSC resources WiX fragment: '$dscWxsFile'" diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp index 8c3ad76448..308b304591 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp @@ -3,6 +3,7 @@ #include "RcResource.h" #include #include +#include #include "../../src/common/logger/logger.h" #include "../../src/common/utils/gpo.h" @@ -232,7 +233,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 +274,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 +429,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 +438,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 +486,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 +497,7 @@ UINT __stdcall UninstallDSCModuleCA(MSIHANDLE hInstall) for (const auto *modulePath : {&versionedModulePath, &powerToysModulePath}) { - fs::remove(*modulePath, errorCode); + std::filesystem::remove(*modulePath, errorCode); if (errorCode) { @@ -1278,7 +1283,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 +1298,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 +1381,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..931a555953 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.def +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def @@ -32,4 +32,4 @@ EXPORTS CleanFileLocksmithRuntimeRegistryCA CleanPowerRenameRuntimeRegistryCA CleanNewPlusRuntimeRegistryCA - \ No newline at end of file + SetBundleInstallLocationCA diff --git a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj index db6f6e6392..7cd49be6ea 100644 --- a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj +++ b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj @@ -65,6 +65,7 @@ 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"""" diff --git a/installer/PowerToysSetupVNext/Core.wxs b/installer/PowerToysSetupVNext/Core.wxs index d3f992d82e..f7da6162f9 100644 --- a/installer/PowerToysSetupVNext/Core.wxs +++ b/installer/PowerToysSetupVNext/Core.wxs @@ -9,6 +9,25 @@ + + + + + + + + + + + + + + + + + + + @@ -109,6 +128,11 @@ + + + + + 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/PowerToysInstallerVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index 0cb9118b91..5341f66768 100644 --- a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -14,6 +14,7 @@ SET PTRoot=$(SolutionDir)\.. call "..\..\..\publish.cmd" x64 ) call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" -Platform "$(Platform)" -nugetHeatPath "$(NUGET_PACKAGES)\wixtoolset.heat\5.0.2" +call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateDscResourcesWxs.ps1 -dscWxsFile "$(MSBuildThisFileDirectory)\DscResources.wxs" -Platform "$(Platform)" -Configuration "$(Configuration)" @@ -24,6 +25,7 @@ SET PTRoot=$(SolutionDir)\.. call "..\..\..\publish.cmd" arm64 ) call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" -Platform "$(Platform)" -nugetHeatPath "$(NUGET_PACKAGES)\wixtoolset.heat\5.0.2" +call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateDscResourcesWxs.ps1 -dscWxsFile "$(MSBuildThisFileDirectory)\DscResources.wxs" -Platform "$(Platform)" -Configuration "$(Configuration)" @@ -39,6 +41,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil 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 @@ -112,9 +115,11 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil + + diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs index e343897d5d..2505557d77 100644 --- a/installer/PowerToysSetupVNext/Product.wxs +++ b/installer/PowerToysSetupVNext/Product.wxs @@ -50,6 +50,7 @@ + @@ -62,6 +63,7 @@ + @@ -69,8 +71,8 @@ - - + + @@ -117,6 +119,8 @@ + + @@ -160,6 +164,9 @@ + + + @@ -244,6 +251,8 @@ + + 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..b6f2f88dd0 100644 --- a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 +++ b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 @@ -182,6 +182,10 @@ Generate-FileComponents -fileListName "HostsAssetsFiles" -wxsFilePath $PSScriptR 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 +# 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 -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 diff --git a/installer/PowerToysSetupVNext/generateDscResourcesWxs.ps1 b/installer/PowerToysSetupVNext/generateDscResourcesWxs.ps1 new file mode 100644 index 0000000000..14172db0bc --- /dev/null +++ b/installer/PowerToysSetupVNext/generateDscResourcesWxs.ps1 @@ -0,0 +1,102 @@ +[CmdletBinding()] +Param( + [Parameter(Mandatory = $True)] + [string]$dscWxsFile, + [Parameter(Mandatory = $True)] + [string]$Platform, + [Parameter(Mandatory = $True)] + [string]$Configuration +) + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Find build output directory +$buildOutputDir = Join-Path $scriptDir "..\..\$Platform\$Configuration" + +if (-not (Test-Path $buildOutputDir)) { + Write-Error "Build output directory not found: '$buildOutputDir'" + exit 1 +} + +# Find all DSC manifest JSON files +$dscFiles = Get-ChildItem -Path $buildOutputDir -Filter "microsoft.powertoys.*.settings.dsc.resource.json" -File + +if (-not $dscFiles) { + Write-Warning "No DSC manifest files found in '$buildOutputDir'" + # Create empty component group + $wxsContent = @" + + + + + + + + + +"@ + Set-Content -Path $dscWxsFile -Value $wxsContent + exit 0 +} + +Write-Host "Found $($dscFiles.Count) DSC manifest file(s)" + +# Generate WiX fragment +$wxsContent = @" + + + + + + +"@ + +$componentRefs = @() + +foreach ($file in $dscFiles) { + $componentId = "DscResource_" + ($file.BaseName -replace '[^A-Za-z0-9_]', '_') + $fileId = $componentId + "_File" + $guid = [System.Guid]::NewGuid().ToString().ToUpper() + + $componentRefs += $componentId + + $wxsContent += @" + + + + + + + +"@ +} + +$wxsContent += @" + + + + + + +"@ + +foreach ($componentId in $componentRefs) { + $wxsContent += @" + + +"@ +} + +$wxsContent += @" + + + + +"@ + +# Write the WiX file +Set-Content -Path $dscWxsFile -Value $wxsContent + +Write-Host "Generated DSC resources WiX fragment: '$dscWxsFile'" \ 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/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..361255f66f 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()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index 33f90e15c9..c0fff9f542 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(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 252b4d128a..630beab9c9 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(); diff --git a/src/common/ManagedCommon/Logger.cs b/src/common/ManagedCommon/Logger.cs index 150d6ea355..11115b1846 100644 --- a/src/common/ManagedCommon/Logger.cs +++ b/src/common/ManagedCommon/Logger.cs @@ -19,7 +19,9 @@ 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"; @@ -151,7 +153,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..aa741e2f3a 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -19,6 +19,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/UITestAutomation.csproj b/src/common/UITestAutomation/UITestAutomation.csproj index 17841e0a60..add7acfeb9 100644 --- a/src/common/UITestAutomation/UITestAutomation.csproj +++ b/src/common/UITestAutomation/UITestAutomation.csproj @@ -17,8 +17,6 @@ - - diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index 00cde3b485..b2e05fadfe 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -81,6 +81,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..471cefe480 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -30,6 +30,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"; @@ -295,6 +296,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); 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..deae2eb832 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.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 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..dcb6abf4a1 --- /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..230cd4556b --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj @@ -0,0 +1,48 @@ + + + + + + + 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..07d4f44bde 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -137,6 +137,16 @@ + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 7fe996abcc..2703358bb0 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -245,6 +245,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 diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml index 1ffabf92a0..da79c36a11 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml @@ -12,156 +12,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesMainPage.xaml b/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesMainPage.xaml index de06561b50..f2628cf375 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesMainPage.xaml +++ b/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesMainPage.xaml @@ -21,157 +21,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 12 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..dff2a67669 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.cpp @@ -0,0 +1,81 @@ +#include "pch.h" +#include +#include "ThemeHelper.h" + +// Controls changing the themes. + +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); + + 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..be3afb170d --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -0,0 +1,570 @@ +#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 +{ + FixedHours, + SunsetToSunrise, + // add more later +}; + +inline std::wstring ToString(ScheduleMode mode) +{ + switch (mode) + { + case ScheduleMode::SunsetToSunrise: + return L"SunsetToSunrise"; + case ScheduleMode::FixedHours: + default: + return L"FixedHours"; + } +} + +inline ScheduleMode FromString(const std::wstring& str) +{ + if (str == L"SunsetToSunrise") + return ScheduleMode::SunsetToSunrise; + return ScheduleMode::FixedHours; +} + +// These are the properties shown in the Settings page. +struct ModuleSettings +{ + bool m_changeSystem = true; + bool m_changeApps = true; + ScheduleMode m_scheduleMode = ScheduleMode::FixedHours; + 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 = CreateDefaultEvent(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"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; + } + + if (auto v = values.get_string_value(L"scheduleMode")) + { + g_settings.m_scheduleMode = FromString(*v); + } + + 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"m_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 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; + } + + 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) + { + 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..168ee092e7 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -0,0 +1,295 @@ +#include +#include +#include "ThemeScheduler.h" +#include "ThemeHelper.h" +#include +#include +#include +#include +#include +#include + +SERVICE_STATUS g_ServiceStatus = {}; +SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; +HANDLE g_ServiceStopEvent = nullptr; +static int g_lastUpdatedDay = -1; + +VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); +VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); +DWORD WINAPI ServiceWorkerThread(LPVOID lpParam); + +// 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 } }; + + 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 + SetEvent(g_ServiceStopEvent); + break; + + default: + break; + } +} + +static void 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; + + 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(); + + OutputDebugString(L"[LightSwitchService] Updated sun times and saved to config.\n"); +} + +DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) +{ + DWORD parentPid = static_cast(reinterpret_cast(lpParam)); + HANDLE hParent = nullptr; + if (parentPid) + hParent = OpenProcess(SYNCHRONIZE, FALSE, parentPid); + + OutputDebugString(L"[LightSwitchService] Worker thread starting...\n"); + + // Initialize settings system + LightSwitchSettings::instance().InitFileWatcher(); + + // Open the manual override event created by the module interface + HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + + auto applyTheme = [](int nowMinutes, int lightMinutes, int darkMinutes, const auto& settings) { + bool isLightActive = false; + + if (lightMinutes < darkMinutes) + { + // Normal case: sunrise < sunset + isLightActive = (nowMinutes >= lightMinutes && nowMinutes < darkMinutes); + } + else + { + // Wraparound case: e.g. light at 21:00, dark at 06:00 + isLightActive = (nowMinutes >= lightMinutes || nowMinutes < darkMinutes); + } + + bool isSystemCurrentlyLight = GetCurrentSystemTheme(); + bool isAppsCurrentlyLight = GetCurrentAppsTheme(); + + if (isLightActive) + { + if (settings.changeSystem && !isSystemCurrentlyLight) + SetSystemTheme(true); + if (settings.changeApps && !isAppsCurrentlyLight) + SetAppsTheme(true); + } + else + { + if (settings.changeSystem && isSystemCurrentlyLight) + SetSystemTheme(false); + if (settings.changeApps && isAppsCurrentlyLight) + SetAppsTheme(false); + } + }; + + // --- At service start: immediately honor the schedule --- + { + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + LightSwitchSettings::instance().LoadSettings(); + const auto& settings = LightSwitchSettings::instance().settings(); + + applyTheme(nowMinutes, settings.lightTime + settings.sunrise_offset, settings.darkTime + settings.sunset_offset, settings); + } + + // --- Main loop: wakes once per minute or stop/parent death --- + for (;;) + { + HANDLE waits[2] = { g_ServiceStopEvent, hParent }; + DWORD count = hParent ? 2 : 1; + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + LightSwitchSettings::instance().LoadSettings(); + const auto& settings = LightSwitchSettings::instance().settings(); + + // Refresh suntimes at day boundary + if (g_lastUpdatedDay != st.wDay) + { + update_sun_times(settings); + g_lastUpdatedDay = st.wDay; + + OutputDebugString(L"[LightSwitchService] Recalculated sun times at new day boundary.\n"); + } + + wchar_t msg[160]; + swprintf_s(msg, + L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d\n", + st.wHour, + st.wMinute, + settings.lightTime / 60, + settings.lightTime % 60, + settings.darkTime / 60, + settings.darkTime % 60); + OutputDebugString(msg); + + // --- Manual override check --- + bool manualOverrideActive = false; + if (hManualOverride) + { + manualOverrideActive = (WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); + } + + if (manualOverrideActive) + { + // Did we hit a scheduled boundary? (reset override at boundary) + if (nowMinutes == (settings.lightTime + settings.sunrise_offset) % 1440 || + nowMinutes == (settings.darkTime + settings.sunset_offset) % 1440) + { + ResetEvent(hManualOverride); + OutputDebugString(L"[LightSwitchService] Manual override cleared at boundary\n"); + } + else + { + OutputDebugString(L"[LightSwitchService] Skipping schedule due to manual override\n"); + goto sleep_until_next_minute; + } + } + + // Apply theme logic (only runs if no manual override or override just cleared) + applyTheme(nowMinutes, settings.lightTime + settings.sunrise_offset, settings.darkTime + settings.sunset_offset, settings); + + sleep_until_next_minute: + 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_OBJECT_0) // stop event + break; + if (hParent && wait == WAIT_OBJECT_0 + 1) // parent exited + break; + } + + if (hManualOverride) + CloseHandle(hManualOverride); + if (hParent) + CloseHandle(hParent); + + 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.\n"); + OutputDebugString(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..82dbcb263a 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..2151d0b5b6 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -0,0 +1,219 @@ + + + + + + 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 + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\ + PowerToys.LightSwitchService + + + + Level3 + true + _DEBUG;%(PreprocessorDefinitions) + true + + + Windows + true + + + + + Level3 + true + true + true + NDEBUG;%(PreprocessorDefinitions) + true + + + Windows + true + + + + + Level3 + true + %(PreprocessorDefinitions) + true + NotUsing + + ./../; + ..\..\..\common\Telemetry; + ..\..\..\common; + ..\..\..\; + ..\..\..\..\deps\spdlog\include; + ./; + %(AdditionalIncludeDirectories) + + + + Windows + true + Advapi32.lib;%(AdditionalDependencies) + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + {1d5be09d-78c0-4fd7-af00-ae7c1af7c525} + + + {8f021b46-362b-485c-bfba-ccf83e820cbd} + + + + + Level3 + true + true + true + NDEBUG;%(PreprocessorDefinitions) + true + + + Windows + true + + + + + NotUsing + + + + + NotUsing + + + NotUsing + + + + + + + + + + + + + + + + false + + + + + + + + + + + + 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/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters new file mode 100644 index 0000000000..a244dfc075 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -0,0 +1,72 @@ + + + + + {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 + + + Source Files + + + Source Files + + + + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header 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..5bd5a1fe92 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp @@ -0,0 +1,167 @@ +#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() +{ + const std::wstring& settingsFileName = GetSettingsFileName(); + m_settingsFileWatcher = std::make_unique(settingsFileName, [&]() { + PostMessageW(HWND_BROADCAST, WM_PRIV_SETTINGS_CHANGED, NULL, NULL); + }); +} + +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); + } + } +} + +void LightSwitchSettings::LoadSettings() +{ + 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 + } +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h new file mode 100644 index 0000000000..51f0988eda --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include + +class SettingsObserver; + +enum class ScheduleMode +{ + 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"FixedHours"; + } +} + +inline ScheduleMode FromString(const std::wstring& str) +{ + if (str == L"SunsetToSunrise") + return ScheduleMode::SunsetToSunrise; + else + return ScheduleMode::FixedHours; +} + +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(); + +private: + LightSwitchSettings(); + ~LightSwitchSettings() = default; + + LightSwitchConfig m_settings; + std::unique_ptr m_settingsFileWatcher; + std::unordered_set m_observers; + + void NotifyObservers(SettingId id) const; +}; 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..88d0194eef --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include "SettingsConstants.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) {} + + 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..b0a57cf468 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp @@ -0,0 +1,81 @@ +#include +#include "ThemeHelper.h" + +// Controls changing the themes. + +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); + + 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/src/modules/LightSwitch/LightSwitchService/resource.h b/src/modules/LightSwitch/LightSwitchService/resource.h new file mode 100644 index 0000000000..e8ed3b4747 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by LightSwitchService.rc +// +#define IDI_ICON1 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif 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..7b586301f6 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs @@ -0,0 +1,439 @@ +// Copyright (c) Microsoft 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 != "Manual") + { + modeCombobox.Click(); + var manualListItem = testBase.Session.Find(By.AccessibilityId("ManualCBItem_LightSwitch"), 5000); + Assert.IsNotNull(manualListItem, "Manual combobox item not found."); + manualListItem.Click(); + neededTabs = 1; + } + + Assert.AreEqual("Manual", modeCombobox.Text, "Mode combobox should be set to Manual."); + + 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 geolocation 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."); + + var setLocationButton = testBase.Session.Find(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(setLocationButton, "Set location button not found."); + setLocationButton.Click(); + + var autoSuggestTextbox = testBase.Session.Find(By.AccessibilityId("CitySearchBox_LightSwitch"), 5000); + Assert.IsNotNull(autoSuggestTextbox, "City search box not found."); + autoSuggestTextbox.Click(); + autoSuggestTextbox.SendKeys("Seattle"); + autoSuggestTextbox.SendKeys(OpenQA.Selenium.Keys.Down); + autoSuggestTextbox.SendKeys(OpenQA.Selenium.Keys.Enter); + + var latLong = testBase.Session.Find(By.AccessibilityId("LocationResultText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(latLong.Text)); + + 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 city button + var setLocationButton = testBase.Session.Find(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(setLocationButton, "Set location button not found."); + setLocationButton.Click(msPostAction: 8000); + + var latLong = testBase.Session.Find(By.AccessibilityId("LocationResultText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(latLong.Text)); + + 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(); + + 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(); + + 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/MouseUtils/FindMyMouse/FindMyMouse.cpp b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp index 3049d3740c..adf5075837 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; + DWORD exStyle = WS_EX_TOOLWINDOW | Shim()->GetExtendedStyle(); + 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..fd144e807b 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp +++ b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp @@ -8,6 +8,7 @@ #include #include #include +#include extern void InclusiveCrosshairsRequestUpdatePosition(); extern void InclusiveCrosshairsEnsureOn(); @@ -30,6 +31,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 +64,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 +75,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 +92,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 +100,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 +128,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 +207,7 @@ public: { m_enabled = false; Trace::EnableMousePointerCrosshairs(false); + UninstallKeyboardHook(); StopXTimer(); StopYTimer(); m_glideState = 0; @@ -220,7 +232,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; } @@ -256,6 +268,27 @@ private: SendInput(2, inputs, sizeof(INPUT)); } + // Cancel gliding without performing the final click (Escape handling) + void CancelGliding() + { + int state = m_glideState.load(); + if (state == 0) + { + return; // nothing to cancel + } + StopXTimer(); + StopYTimer(); + m_glideState = 0; + InclusiveCrosshairsEnsureOff(); + InclusiveCrosshairsSetExternalControl(false); + if (auto s = m_state) + { + s->xFraction = 0.0; + s->yFraction = 0.0; + } + Logger::debug("Gliding cursor cancelled via Escape key"); + } + // Stateless helpers operating on shared State static void PositionCursorX(const std::shared_ptr& s) { @@ -398,10 +431,14 @@ private: { case 0: { + // For detect for cancel key + InstallKeyboardHook(); // Ensure crosshairs on (do not toggle off if already on) InclusiveCrosshairsEnsureOn(); // Disable internal mouse hook so we control position updates explicitly InclusiveCrosshairsSetExternalControl(true); + // Override crosshairs to show both for Gliding Cursor + InclusiveCrosshairsSetOrientation(CrosshairsOrientation::Both); s->currentXPos = 0; s->currentXSpeed = s->fastHSpeed; @@ -444,12 +481,15 @@ private: case 4: default: { + UninstallKeyboardHook(); // Stop vertical, click, turn crosshairs off, re-enable internal tracking, reset state StopYTimer(); m_glideState = 0; LeftClick(); InclusiveCrosshairsEnsureOff(); InclusiveCrosshairsSetExternalControl(false); + // Restore original crosshairs orientation setting + InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation); s->xFraction = 0.0; s->yFraction = 0.0; break; @@ -457,6 +497,51 @@ private: } } + // Low-level keyboard hook procedures + 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->UninstallKeyboardHook(); + inst->CancelGliding(); + } + } + } + } + + // Do not swallow Escape; pass it through + 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 +560,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 +848,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 +865,7 @@ private: m_glidingHotkey.shift = false; m_glidingHotkey.key = VK_OEM_PERIOD; } + m_inclusiveCrosshairsSettings = inclusiveCrosshairsSettings; } }; diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.Clipboard.cs b/src/modules/MouseWithoutBorders/App/Class/Common.Clipboard.cs deleted file mode 100644 index 51be48ec5a..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.Clipboard.cs +++ /dev/null @@ -1,1162 +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.Collections.Specialized; -using System.Diagnostics; -using System.Drawing; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net.Sockets; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; - -using Microsoft.PowerToys.Telemetry; - -// -// Clipboard related routines. -// -// -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// -using MouseWithoutBorders.Class; -using MouseWithoutBorders.Core; -using MouseWithoutBorders.Exceptions; - -using SystemClipboard = System.Windows.Forms.Clipboard; -using Thread = MouseWithoutBorders.Core.Thread; - -namespace MouseWithoutBorders -{ - internal partial class Common - { - internal static readonly char[] Comma = new char[] { ',' }; - internal static readonly char[] Star = new char[] { '*' }; - internal static readonly char[] NullSeparator = new char[] { '\0' }; - - internal const uint BIG_CLIPBOARD_DATA_TIMEOUT = 30000; - private const uint MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP = 1024 * 1024; // 1MB - private const uint MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT = 100 * 1024 * 1024; // 100MB - private const int TEXT_HEADER_SIZE = 12; - private const int DATA_SIZE = 48; - private const string TEXT_TYPE_SEP = "{4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F}"; - private static long lastClipboardEventTime; - private static string lastMachineWithClipboardData; - private static string lastDragDropFile; -#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter - internal static long clipboardCopiedTime; -#pragma warning restore SA1307 - - internal static ID LastIDWithClipboardData { get; set; } - - internal static string LastDragDropFile - { - get => Common.lastDragDropFile; - set => Common.lastDragDropFile = value; - } - - internal static string LastMachineWithClipboardData - { - get => Common.lastMachineWithClipboardData; - set => Common.lastMachineWithClipboardData = value; - } - - internal static long LastClipboardEventTime - { - get => Common.lastClipboardEventTime; - set => Common.lastClipboardEventTime = value; - } - - internal static IntPtr NextClipboardViewer { get; set; } - - internal static bool IsClipboardDataImage { get; private set; } - - internal static byte[] LastClipboardData { get; private set; } - - private static object lastClipboardObject = string.Empty; - - internal static bool HasSwitchedMachineSinceLastCopy { get; set; } - - internal static bool CheckClipboardEx(ByteArrayOrString data, bool isFilePath) - { - Logger.LogDebug($"{nameof(CheckClipboardEx)}: ShareClipboard = {Setting.Values.ShareClipboard}, TransferFile = {Setting.Values.TransferFile}, data = {data}."); - Logger.LogDebug($"{nameof(CheckClipboardEx)}: {nameof(Setting.Values.OneWayClipboardMode)} = {Setting.Values.OneWayClipboardMode}."); - - if (!Setting.Values.ShareClipboard) - { - return false; - } - - if (Common.RunWithNoAdminRight && Setting.Values.OneWayClipboardMode) - { - return false; - } - - if (GetTick() - LastClipboardEventTime < 1000) - { - Logger.LogDebug("GetTick() - lastClipboardEventTime < 1000"); - LastClipboardEventTime = GetTick(); - return false; - } - - LastClipboardEventTime = GetTick(); - - try - { - IsClipboardDataImage = false; - LastClipboardData = null; - LastDragDropFile = null; - GC.Collect(); - - string stringData = null; - byte[] byteData = null; - - if (data.IsByteArray) - { - byteData = data.GetByteArray(); - } - else - { - stringData = data.GetString(); - } - - if (stringData != null) - { - if (!HasSwitchedMachineSinceLastCopy) - { - if (lastClipboardObject is string lastStringData && lastStringData.Equals(stringData, StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("CheckClipboardEx: Same string data."); - return false; - } - } - - HasSwitchedMachineSinceLastCopy = false; - - if (isFilePath) - { - Logger.LogDebug("Clipboard contains FileDropList"); - - if (!Setting.Values.TransferFile) - { - Logger.LogDebug("TransferFile option is unchecked."); - return false; - } - - string filePath = stringData; - - _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => - { - if (File.Exists(filePath) || Directory.Exists(filePath)) - { - if (File.Exists(filePath) && new FileInfo(filePath).Length <= MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT) - { - Logger.LogDebug("Clipboard contains: " + filePath); - LastDragDropFile = filePath; - SendClipboardBeat(); - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_BIG_CLIPBOARD, -1, ICON_BIG_CLIPBOARD, -1 }); - } - else - { - if (Directory.Exists(filePath)) - { - Logger.LogDebug("Clipboard contains a directory: " + filePath); - LastDragDropFile = filePath; - SendClipboardBeat(); - } - else - { - LastDragDropFile = filePath + " - File too big (greater than 100MB), please drag and drop the file instead!"; - SendClipboardBeat(); - Logger.Log("Clipboard: File too big: " + filePath); - } - - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_ERROR, -1, ICON_ERROR, -1 }); - } - } - else - { - Logger.Log("CheckClipboardEx: File not found: " + filePath); - } - }); - } - else - { - byte[] texts = Common.GetBytesU(stringData); - - using MemoryStream ms = new(); - using (DeflateStream s = new(ms, CompressionMode.Compress, true)) - { - s.Write(texts, 0, texts.Length); - } - - Logger.LogDebug("Plain/Zip = " + texts.Length.ToString(CultureInfo.CurrentCulture) + "/" + - ms.Length.ToString(CultureInfo.CurrentCulture)); - - LastClipboardData = ms.GetBuffer(); - } - } - else if (byteData != null) - { - if (!HasSwitchedMachineSinceLastCopy) - { - if (lastClipboardObject is byte[] lastByteData && Enumerable.SequenceEqual(lastByteData, byteData)) - { - Logger.LogDebug("CheckClipboardEx: Same byte[] data."); - return false; - } - } - - HasSwitchedMachineSinceLastCopy = false; - - Logger.LogDebug("Clipboard contains image"); - IsClipboardDataImage = true; - LastClipboardData = byteData; - } - else - { - Logger.LogDebug("*** Clipboard contains something else!"); - return false; - } - - lastClipboardObject = data; - - if (LastClipboardData != null && LastClipboardData.Length > 0) - { - if (LastClipboardData.Length > MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP) - { - SendClipboardBeat(); - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_BIG_CLIPBOARD, -1, ICON_BIG_CLIPBOARD, -1 }); - } - else - { - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_SMALL_CLIPBOARD, -1, -1, -1 }); - SendClipboardDataUsingTCP(LastClipboardData, IsClipboardDataImage); - } - - return true; - } - } - catch (Exception e) - { - Logger.Log(e); - } - - return false; - } - - private static void SendClipboardDataUsingTCP(byte[] bytes, bool image) - { - if (Sk == null) - { - return; - } - - new Task(() => - { - // SuppressFlow fixes an issue on service mode, where the helper process can't get enough permissions to be started again. - // More details can be found on: https://github.com/microsoft/PowerToys/pull/36892 - using var asyncFlowControl = ExecutionContext.SuppressFlow(); - - System.Threading.Thread thread = Thread.CurrentThread; - thread.Name = $"{nameof(SendClipboardDataUsingTCP)}.{thread.ManagedThreadId}"; - Thread.UpdateThreads(thread); - int l = bytes.Length; - int index = 0; - int len; - DATA package = new(); - byte[] buf = new byte[PACKAGE_SIZE_EX]; - int dataStart = PACKAGE_SIZE_EX - DATA_SIZE; - - while (true) - { - if ((index + DATA_SIZE) > l) - { - len = l - index; - Array.Clear(buf, 0, PACKAGE_SIZE_EX); - } - else - { - len = DATA_SIZE; - } - - Array.Copy(bytes, index, buf, dataStart, len); - package.Bytes = buf; - - package.Type = image ? PackageType.ClipboardImage : PackageType.ClipboardText; - package.Des = ID.ALL; - SkSend(package, (uint)MachineID, false); - - index += DATA_SIZE; - if (index >= l) - { - break; - } - } - - package.Type = PackageType.ClipboardDataEnd; - package.Des = ID.ALL; - SkSend(package, (uint)MachineID, false); - }).Start(); - } - - internal static void ReceiveClipboardDataUsingTCP(DATA data, bool image, TcpSk tcp) - { - try - { - if (Sk == null || RunOnLogonDesktop || RunOnScrSaverDesktop) - { - return; - } - - MemoryStream m = new(); - int dataStart = PACKAGE_SIZE_EX - DATA_SIZE; - m.Write(data.Bytes, dataStart, DATA_SIZE); - int unexpectedCount = 0; - - bool done = false; - do - { - data = SocketStuff.TcpReceiveData(tcp, out int err); - - switch (data.Type) - { - case PackageType.ClipboardImage: - case PackageType.ClipboardText: - m.Write(data.Bytes, dataStart, DATA_SIZE); - break; - - case PackageType.ClipboardDataEnd: - done = true; - break; - - default: - Receiver.ProcessPackage(data, tcp); - if (++unexpectedCount > 100) - { - Logger.Log("ReceiveClipboardDataUsingTCP: unexpectedCount > 100!"); - done = true; - } - - break; - } - } - while (!done); - - LastClipboardEventTime = GetTick(); - - if (image) - { - Image im = Image.FromStream(m); - Clipboard.SetImage(im); - LastClipboardEventTime = GetTick(); - } - else - { - Common.SetClipboardData(m.GetBuffer()); - LastClipboardEventTime = GetTick(); - } - - m.Dispose(); - - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_SMALL_CLIPBOARD, -1, ICON_SMALL_CLIPBOARD, -1 }); - } - catch (Exception e) - { - Logger.Log("ReceiveClipboardDataUsingTCP: " + e.Message); - } - } - - private static readonly Lock ClipboardThreadOldLock = new(); - private static System.Threading.Thread clipboardThreadOld; - - internal static void GetRemoteClipboard(string postAction) - { - if (!RunOnLogonDesktop && !RunOnScrSaverDesktop) - { - if (Common.LastMachineWithClipboardData == null || - Common.LastMachineWithClipboardData.Length < 1) - { - return; - } - - new Task(() => - { - // SuppressFlow fixes an issue on service mode, where the helper process can't get enough permissions to be started again. - // More details can be found on: https://github.com/microsoft/PowerToys/pull/36892 - using var asyncFlowControl = ExecutionContext.SuppressFlow(); - - System.Threading.Thread thread = Thread.CurrentThread; - thread.Name = $"{nameof(ConnectAndGetData)}.{thread.ManagedThreadId}"; - Thread.UpdateThreads(thread); - ConnectAndGetData(postAction); - }).Start(); - } - } - - private static Stream m; - - private static void ConnectAndGetData(object postAction) - { - if (Sk == null) - { - Logger.Log("ConnectAndGetData: Sk == null!"); - return; - } - - string remoteMachine; - TcpClient clipboardTcpClient = null; - string postAct = (string)postAction; - - Logger.LogDebug("ConnectAndGetData.postAction: " + postAct); - - ClipboardPostAction clipboardPostAct = postAct.Contains("mspaint,") ? ClipboardPostAction.Mspaint - : postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase) ? ClipboardPostAction.Desktop - : ClipboardPostAction.Other; - - try - { - remoteMachine = postAct.Contains("mspaint,") ? postAct.Split(Comma)[1] : Common.LastMachineWithClipboardData; - - remoteMachine = remoteMachine.Trim(); - - if (!IsConnectedByAClientSocketTo(remoteMachine)) - { - Logger.Log($"No potential inbound connection from {MachineName} to {remoteMachine}, ask for a push back instead."); - ID machineId = MachineStuff.MachinePool.ResolveID(remoteMachine); - - if (machineId != ID.NONE) - { - SkSend( - new DATA() - { - Type = PackageType.ClipboardAsk, - Des = machineId, - MachineName = MachineName, - PostAction = clipboardPostAct, - }, - null, - false); - } - else - { - Logger.Log($"Unable to resolve {remoteMachine} to its long IP."); - } - - return; - } - - ShowToolTip("Connecting to " + remoteMachine, 2000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); - - clipboardTcpClient = ConnectToRemoteClipboardSocket(remoteMachine); - } - catch (ThreadAbortException) - { - Logger.Log("The current thread is being aborted (1)."); - if (clipboardTcpClient != null && clipboardTcpClient.Connected) - { - clipboardTcpClient.Client.Close(); - } - - return; - } - catch (Exception e) - { - Logger.Log(e); - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, Common.ICON_BIG_CLIPBOARD, -1, - }); - ShowToolTip(e.Message, 1000, ToolTipIcon.Warning, Setting.Values.ShowClipNetStatus); - return; - } - - bool clientPushData = false; - - if (!ShakeHand(ref remoteMachine, clipboardTcpClient.Client, out Stream enStream, out Stream deStream, ref clientPushData, ref clipboardPostAct)) - { - return; - } - - ReceiveAndProcessClipboardData(remoteMachine, clipboardTcpClient.Client, enStream, deStream, postAct); - } - - internal static void ReceiveAndProcessClipboardData(string remoteMachine, Socket s, Stream enStream, Stream deStream, string postAct) - { - lock (ClipboardThreadOldLock) - { - // Do not enable two connections at the same time. - if (clipboardThreadOld != null && clipboardThreadOld.ThreadState != System.Threading.ThreadState.AbortRequested - && clipboardThreadOld.ThreadState != System.Threading.ThreadState.Aborted && clipboardThreadOld.IsAlive - && clipboardThreadOld.ManagedThreadId != Thread.CurrentThread.ManagedThreadId) - { - if (clipboardThreadOld.Join(3000)) - { - if (m != null) - { - m.Flush(); - m.Close(); - m = null; - } - } - } - - clipboardThreadOld = Thread.CurrentThread; - } - - try - { - byte[] header = new byte[1024]; - byte[] buf = new byte[NETWORK_STREAM_BUF_SIZE]; - string fileName = null; - string tempFile = "data", savingFolder = string.Empty; - Common.ToggleIconsIndex = 0; - int rv; - long receivedCount = 0; - - if ((rv = deStream.ReadEx(header, 0, header.Length)) < header.Length) - { - Logger.Log("Reading header failed: " + rv.ToString(CultureInfo.CurrentCulture)); - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, -1, -1, - }); - return; - } - - fileName = Common.GetStringU(header).Replace("\0", string.Empty); - Logger.LogDebug("Header: " + fileName); - string[] headers = fileName.Split(Star); - - if (headers.Length < 2 || !long.TryParse(headers[0], out long dataSize)) - { - Logger.Log(string.Format( - CultureInfo.CurrentCulture, - "Reading header failed: {0}:{1}", - headers.Length, - fileName)); - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, -1, -1, - }); - return; - } - - fileName = headers[1]; - - Logger.LogDebug(string.Format( - CultureInfo.CurrentCulture, - "Receiving {0}:{1} from {2}...", - Path.GetFileName(fileName), - dataSize, - remoteMachine)); - ShowToolTip( - string.Format( - CultureInfo.CurrentCulture, - "Receiving {0} from {1}...", - Path.GetFileName(fileName), - remoteMachine), - 5000, - ToolTipIcon.Info, - Setting.Values.ShowClipNetStatus); - if (fileName.StartsWith("image", StringComparison.CurrentCultureIgnoreCase) || - fileName.StartsWith("text", StringComparison.CurrentCultureIgnoreCase)) - { - m = new MemoryStream(); - } - else - { - if (postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase)) - { - _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => - { - savingFolder = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + "\\MouseWithoutBorders\\"; - - if (!Directory.Exists(savingFolder)) - { - _ = Directory.CreateDirectory(savingFolder); - } - }); - - tempFile = savingFolder + Path.GetFileName(fileName); - m = new FileStream(tempFile, FileMode.Create); - } - else if (postAct.Contains("mspaint")) - { - tempFile = GetMyStorageDir() + @"ScreenCapture-" + - remoteMachine + ".png"; - m = new FileStream(tempFile, FileMode.Create); - } - else - { - tempFile = GetMyStorageDir(); - tempFile += Path.GetFileName(fileName); - m = new FileStream(tempFile, FileMode.Create); - } - - Logger.Log("==> " + tempFile); - } - - ShowToolTip( - string.Format( - CultureInfo.CurrentCulture, - "Receiving {0} from {1}...", - Path.GetFileName(fileName), - remoteMachine), - 5000, - ToolTipIcon.Info, - Setting.Values.ShowClipNetStatus); - - do - { - rv = deStream.ReadEx(buf, 0, buf.Length); - - if (rv > 0) - { - receivedCount += rv; - - if (receivedCount > dataSize) - { - rv -= (int)(receivedCount - dataSize); - } - - m.Write(buf, 0, rv); - } - - if (Common.ToggleIcons == null) - { - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_SMALL_CLIPBOARD, - -1, Common.ICON_SMALL_CLIPBOARD, -1, - }); - } - - string text = string.Format(CultureInfo.CurrentCulture, "{0}KB received: {1}", m.Length / 1024, Path.GetFileName(fileName)); - - DoSomethingInUIThread(() => - { - MainForm.SetTrayIconText(text); - }); - } - while (rv > 0); - - if (m != null && fileName != null) - { - m.Flush(); - Logger.LogDebug(m.Length.ToString(CultureInfo.CurrentCulture) + " bytes received."); - Common.LastClipboardEventTime = Common.GetTick(); - string toolTipText = null; - string sizeText = m.Length >= 1024 - ? (m.Length / 1024).ToString(CultureInfo.CurrentCulture) + "KB" - : m.Length.ToString(CultureInfo.CurrentCulture) + "Bytes"; - - PowerToysTelemetry.Log.WriteEvent(new MouseWithoutBorders.Telemetry.MouseWithoutBordersClipboardFileTransferEvent()); - - if (fileName.StartsWith("image", StringComparison.CurrentCultureIgnoreCase)) - { - Clipboard.SetImage(Image.FromStream(m)); - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} from {2} is in Clipboard.", - sizeText, - "image", - remoteMachine); - } - else if (fileName.StartsWith("text", StringComparison.CurrentCultureIgnoreCase)) - { - byte[] data = (m as MemoryStream).GetBuffer(); - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} from {2} is in Clipboard.", - sizeText, - "text", - remoteMachine); - Common.SetClipboardData(data); - } - else if (tempFile != null) - { - if (postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase)) - { - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} received from {2}!", - sizeText, - Path.GetFileName(fileName), - remoteMachine); - - _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => - { - ProcessStartInfo startInfo = new(); - startInfo.UseShellExecute = true; - startInfo.WorkingDirectory = savingFolder; - startInfo.FileName = savingFolder; - startInfo.Verb = "open"; - _ = Process.Start(startInfo); - }); - } - else if (postAct.Contains("mspaint")) - { - m.Close(); - m = null; - OpenImage(tempFile); - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} from {2} is in Mspaint.", - sizeText, - Path.GetFileName(tempFile), - remoteMachine); - } - else - { - StringCollection filePaths = new() - { - tempFile, - }; - Clipboard.SetFileDropList(filePaths); - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} from {2} is in Clipboard.", - sizeText, - Path.GetFileName(fileName), - remoteMachine); - } - } - - if (!string.IsNullOrWhiteSpace(toolTipText)) - { - Common.ShowToolTip(toolTipText, 5000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); - } - - DoSomethingInUIThread(() => - { - MainForm.UpdateNotifyIcon(); - }); - - m?.Close(); - m = null; - } - } - catch (ThreadAbortException) - { - Logger.Log("The current thread is being aborted (3)."); - s.Close(); - - if (m != null) - { - m.Close(); - m = null; - } - - return; - } - catch (Exception e) - { - if (e is IOException) - { - string log = $"{nameof(ReceiveAndProcessClipboardData)}: Exception accessing the socket: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; - Logger.Log(log); - } - else - { - Logger.Log(e); - } - - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, Common.ICON_BIG_CLIPBOARD, -1, - }); - ShowToolTip(e.Message, 1000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); - - if (m != null) - { - m.Close(); - m = null; - } - - return; - } - - s.Close(); - } - - internal static bool ShakeHand(ref string remoteName, Socket s, out Stream enStream, out Stream deStream, ref bool clientPushData, ref ClipboardPostAction postAction) - { - const int CLIPBOARD_HANDSHAKE_TIMEOUT = 30; - s.ReceiveTimeout = CLIPBOARD_HANDSHAKE_TIMEOUT * 1000; - s.NoDelay = true; - s.SendBufferSize = s.ReceiveBufferSize = 1024000; - - bool handShaken = false; - enStream = deStream = null; - - try - { - DATA package = new() - { - Type = clientPushData ? PackageType.ClipboardPush : PackageType.Clipboard, - PostAction = postAction, - Src = MachineID, - MachineName = MachineName, - }; - - byte[] buf = new byte[PACKAGE_SIZE_EX]; - - NetworkStream ns = new(s); - enStream = Common.GetEncryptedStream(ns); - Common.SendOrReceiveARandomDataBlockPerInitialIV(enStream); - Logger.LogDebug($"{nameof(ShakeHand)}: Writing header package."); - enStream.Write(package.Bytes, 0, PACKAGE_SIZE_EX); - - Logger.LogDebug($"{nameof(ShakeHand)}: Sent: clientPush={clientPushData}, postAction={postAction}."); - - deStream = Common.GetDecryptedStream(ns); - Common.SendOrReceiveARandomDataBlockPerInitialIV(deStream, false); - - Logger.LogDebug($"{nameof(ShakeHand)}: Reading header package."); - - int bytesReceived = deStream.ReadEx(buf, 0, Common.PACKAGE_SIZE_EX); - package.Bytes = buf; - - string name = "Unknown"; - - if (bytesReceived == Common.PACKAGE_SIZE_EX) - { - if (package.Type is PackageType.Clipboard or PackageType.ClipboardPush) - { - name = remoteName = package.MachineName; - - Logger.LogDebug($"{nameof(ShakeHand)}: Connection from {name}:{package.Src}"); - - if (MachineStuff.MachinePool.ResolveID(name) == package.Src && Common.IsConnectedTo(package.Src)) - { - clientPushData = package.Type == PackageType.ClipboardPush; - postAction = package.PostAction; - handShaken = true; - Logger.LogDebug($"{nameof(ShakeHand)}: Received: clientPush={clientPushData}, postAction={postAction}."); - } - else - { - Logger.LogDebug($"{nameof(ShakeHand)}: No active connection to the machine: {name}."); - } - } - else - { - Logger.LogDebug($"{nameof(ShakeHand)}: Unexpected package type: {package.Type}."); - } - } - else - { - Logger.LogDebug($"{nameof(ShakeHand)}: BytesTransferred != PACKAGE_SIZE_EX: {bytesReceived}"); - } - - if (!handShaken) - { - string msg = $"Clipboard connection rejected: {name}:{remoteName}/{package.Src}\r\n\r\nMake sure you run the same version in all machines."; - Logger.Log(msg); - Common.ShowToolTip(msg, 3000, ToolTipIcon.Warning); - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_BIG_CLIPBOARD, -1, -1, -1 }); - } - } - catch (ThreadAbortException) - { - Logger.Log($"{nameof(ShakeHand)}: The current thread is being aborted."); - s.Close(); - } - catch (Exception e) - { - if (e is IOException) - { - string log = $"{nameof(ShakeHand)}: Exception accessing the socket: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; - Logger.Log(log); - } - else - { - Logger.Log(e); - } - - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, Common.ICON_BIG_CLIPBOARD, -1, - }); - MainForm.UpdateNotifyIcon(); - ShowToolTip(e.Message + "\r\n\r\nMake sure you run the same version in all machines.", 1000, ToolTipIcon.Warning, Setting.Values.ShowClipNetStatus); - - if (m != null) - { - m.Close(); - m = null; - } - } - - return handShaken; - } - - internal static TcpClient ConnectToRemoteClipboardSocket(string remoteMachine) - { - TcpClient clipboardTcpClient; - clipboardTcpClient = new TcpClient(AddressFamily.InterNetworkV6); - clipboardTcpClient.Client.DualMode = true; - - SocketStuff sk = Common.Sk; - - if (sk != null) - { - Common.DoSomethingInUIThread(() => Common.MainForm.ChangeIcon(Common.ICON_SMALL_CLIPBOARD)); - - System.Net.IPAddress ip = GetConnectedClientSocketIPAddressFor(remoteMachine); - Logger.LogDebug($"{nameof(ConnectToRemoteClipboardSocket)}Connecting to {remoteMachine}:{ip}:{sk.TcpPort}..."); - - if (ip != null) - { - clipboardTcpClient.Connect(ip, sk.TcpPort); - } - else - { - clipboardTcpClient.Connect(remoteMachine, sk.TcpPort); - } - - Logger.LogDebug($"Connected from {clipboardTcpClient.Client.LocalEndPoint}. Getting data..."); - return clipboardTcpClient; - } - else - { - throw new ExpectedSocketException($"{nameof(ConnectToRemoteClipboardSocket)}: No longer connected."); - } - } - - internal static void SetClipboardData(byte[] data) - { - if (data == null || data.Length <= 0) - { - Logger.Log("data is null or empty!"); - return; - } - - if (data.Length > 1024000) - { - ShowToolTip( - string.Format( - CultureInfo.CurrentCulture, - "Decompressing {0} clipboard data ...", - (data.Length / 1024).ToString(CultureInfo.CurrentCulture) + "KB"), - 5000, - ToolTipIcon.Info, - Setting.Values.ShowClipNetStatus); - } - - string st = string.Empty; - - using (MemoryStream ms = new(data)) - { - using DeflateStream s = new(ms, CompressionMode.Decompress, true); - const int BufferSize = 1024000; // Buffer size should be big enough, this is critical to performance! - - int rv = 0; - - do - { - byte[] buffer = new byte[BufferSize]; - rv = s.ReadEx(buffer, 0, BufferSize); - - if (rv > 0) - { - st += Common.GetStringU(buffer); - } - else - { - break; - } - } - while (true); - } - - int textTypeCount = 0; - string[] texts = st.Split(new string[] { TEXT_TYPE_SEP }, StringSplitOptions.RemoveEmptyEntries); - string tmp; - DataObject data1 = new(); - - foreach (string txt in texts) - { - if (string.IsNullOrEmpty(txt.Trim(NullSeparator))) - { - continue; - } - - tmp = txt[3..]; - - if (txt.StartsWith("RTF", StringComparison.CurrentCultureIgnoreCase)) - { - Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of RTF <-"); - data1.SetData(DataFormats.Rtf, tmp); - } - else if (txt.StartsWith("HTM", StringComparison.CurrentCultureIgnoreCase)) - { - Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of HTM <-"); - data1.SetData(DataFormats.Html, tmp); - } - else if (txt.StartsWith("TXT", StringComparison.CurrentCultureIgnoreCase)) - { - Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of TXT <-"); - data1.SetData(DataFormats.UnicodeText, tmp); - } - else - { - if (textTypeCount == 0) - { - Logger.LogDebug(((double)txt.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of UNI <-"); - data1.SetData(DataFormats.UnicodeText, txt); - } - - Logger.Log("Invalid clipboard format received!"); - } - - textTypeCount++; - } - - if (textTypeCount > 0) - { - Clipboard.SetDataObject(data1); - } - } - } - - internal static class Clipboard - { - public static void SetFileDropList(StringCollection filePaths) - { - Common.DoSomethingInUIThread(() => - { - try - { - _ = Common.Retry( - nameof(SystemClipboard.SetFileDropList), - () => - { - SystemClipboard.SetFileDropList(filePaths); - return true; - }, - (log) => Logger.TelemetryLogTrace( - log, - SeverityLevel.Information), - () => Common.LastClipboardEventTime = Common.GetTick()); - } - catch (ExternalException e) - { - Logger.Log(e); - } - catch (ThreadStateException e) - { - Logger.Log(e); - } - catch (ArgumentNullException e) - { - Logger.Log(e); - } - catch (ArgumentException e) - { - Logger.Log(e); - } - }); - } - - public static void SetImage(Image image) - { - Common.DoSomethingInUIThread(() => - { - try - { - _ = Common.Retry( - nameof(SystemClipboard.SetImage), - () => - { - SystemClipboard.SetImage(image); - return true; - }, - (log) => Logger.TelemetryLogTrace(log, SeverityLevel.Information), - () => Common.LastClipboardEventTime = Common.GetTick()); - } - catch (ExternalException e) - { - Logger.Log(e); - } - catch (ThreadStateException e) - { - Logger.Log(e); - } - catch (ArgumentNullException e) - { - Logger.Log(e); - } - }); - } - - public static void SetText(string text) - { - Common.DoSomethingInUIThread(() => - { - try - { - _ = Common.Retry( - nameof(SystemClipboard.SetText), - () => - { - SystemClipboard.SetText(text); - return true; - }, - (log) => Logger.TelemetryLogTrace(log, SeverityLevel.Information), - () => Common.LastClipboardEventTime = Common.GetTick()); - } - catch (ExternalException e) - { - Logger.Log(e); - } - catch (ThreadStateException e) - { - Logger.Log(e); - } - catch (ArgumentNullException e) - { - Logger.Log(e); - } - }); - } - - public static void SetDataObject(DataObject dataObject) - { - Common.DoSomethingInUIThread(() => - { - try - { - SystemClipboard.SetDataObject(dataObject, true, 10, 200); - } - catch (ExternalException e) - { - string dataFormats = string.Join(",", dataObject.GetFormats()); - Logger.Log($"{e.Message}: {dataFormats}"); - } - catch (ThreadStateException e) - { - Logger.Log(e); - } - catch (ArgumentNullException e) - { - Logger.Log(e); - } - }); - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.InitAndCleanup.cs b/src/modules/MouseWithoutBorders/App/Class/Common.InitAndCleanup.cs deleted file mode 100644 index 44861926e9..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.InitAndCleanup.cs +++ /dev/null @@ -1,284 +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.Globalization; -using System.Linq; -using System.Net.NetworkInformation; -using System.Security.Cryptography; -using System.Threading; - -// -// Initialization and clean up. -// -// -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// -using Microsoft.Win32; -using MouseWithoutBorders.Class; -using MouseWithoutBorders.Core; -using MouseWithoutBorders.Form; -using Windows.UI.Input.Preview.Injection; - -using Thread = MouseWithoutBorders.Core.Thread; - -namespace MouseWithoutBorders -{ - internal partial class Common - { - private static bool initDone; - internal static int REOPEN_WHEN_WSAECONNRESET = -10054; - internal static int REOPEN_WHEN_HOTKEY = -10055; - internal static int PleaseReopenSocket; - internal static bool ReopenSocketDueToReadError; - - internal static DateTime LastResumeSuspendTime { get; set; } = DateTime.UtcNow; - - internal static bool InitDone - { - get => Common.initDone; - set => Common.initDone = value; - } - - internal static void UpdateMachineTimeAndID() - { - Common.MachineName = Common.MachineName.Trim(); - _ = MachineStuff.MachinePool.TryUpdateMachineID(Common.MachineName, Common.MachineID, true); - } - - private static void InitializeMachinePoolFromSettings() - { - try - { - MachineInf[] info = MachinePoolHelpers.LoadMachineInfoFromMachinePoolStringSetting(Setting.Values.MachinePoolString); - for (int i = 0; i < info.Length; i++) - { - info[i].Name = info[i].Name.Trim(); - } - - MachineStuff.MachinePool.Initialize(info); - MachineStuff.MachinePool.ResetIPAddressesForDeadMachines(true); - } - catch (Exception ex) - { - Logger.Log(ex); - MachineStuff.MachinePool.Clear(); - } - } - - internal static void SetupMachineNameAndID() - { - try - { - GetMachineName(); - DesMachineID = MachineStuff.NewDesMachineID = MachineID; - - // MessageBox.Show(machineID.ToString(CultureInfo.CurrentCulture)); // For test - InitializeMachinePoolFromSettings(); - - Common.MachineName = Common.MachineName.Trim(); - _ = MachineStuff.MachinePool.LearnMachine(Common.MachineName); - _ = MachineStuff.MachinePool.TryUpdateMachineID(Common.MachineName, Common.MachineID, true); - - MachineStuff.UpdateMachinePoolStringSetting(); - } - catch (Exception e) - { - Logger.Log(e); - } - } - - internal static void Init() - { - _ = Helper.GetUserName(); - Common.GeneratedKey = true; - - try - { - Common.MyKey = Setting.Values.MyKey; - int tmp = Setting.Values.MyKeyDaysToExpire; - } - catch (FormatException e) - { - Common.KeyCorrupted = true; - Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); - Logger.Log(e.Message); - } - catch (CryptographicException e) - { - Common.KeyCorrupted = true; - Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); - Logger.Log(e.Message); - } - - try - { - InputSimulation.Injector = InputInjector.TryCreate(); - if (InputSimulation.Injector != null) - { - InputSimulation.MoveMouseRelative(0, 0); - NativeMethods.InjectMouseInputAvailable = true; - } - } - catch (EntryPointNotFoundException) - { - NativeMethods.InjectMouseInputAvailable = false; - Logger.Log($"{nameof(NativeMethods.InjectMouseInputAvailable)} = false"); - } - - bool dummy = Setting.Values.DrawMouseEx; - Is64bitOS = IntPtr.Size == 8; - tcpPort = Setting.Values.TcpPort; - GetScreenConfig(); - PackageSent = new PackageMonitor(0); - PackageReceived = new PackageMonitor(0); - SetupMachineNameAndID(); - InitEncryption(); - CreateHelperThreads(); - - SystemEvents.DisplaySettingsChanged += new EventHandler(SystemEvents_DisplaySettingsChanged); - NetworkChange.NetworkAvailabilityChanged += new NetworkAvailabilityChangedEventHandler(NetworkChange_NetworkAvailabilityChanged); - SystemEvents.PowerModeChanged += new PowerModeChangedEventHandler(SystemEvents_PowerModeChanged); - PleaseReopenSocket = 9; - /* TODO: Telemetry for the matrix? */ - } - - private static void SystemEvents_PowerModeChanged(object sender, PowerModeChangedEventArgs e) - { - Helper.WndProcCounter++; - - if (e.Mode is PowerModes.Resume or PowerModes.Suspend) - { - Logger.TelemetryLogTrace($"{nameof(SystemEvents_PowerModeChanged)}: {e.Mode}", SeverityLevel.Information); - LastResumeSuspendTime = DateTime.UtcNow; - MachineStuff.SwitchToMultipleMode(false, true); - } - } - - private static void CreateHelperThreads() - { - // NOTE(@yuyoyuppe): service crashes while trying to obtain this info, disabling. - /* - Thread watchDogThread = new(new ThreadStart(WatchDogThread), nameof(WatchDogThread)); - watchDogThread.Priority = ThreadPriority.Highest; - watchDogThread.Start(); - */ - - helper = new Thread(new ThreadStart(Helper.HelperThread), "Helper Thread"); - helper.SetApartmentState(ApartmentState.STA); - helper.Start(); - } - - private static void AskHelperThreadsToExit(int waitTime) - { - Helper.signalHelperToExit = true; - Helper.signalWatchDogToExit = true; - _ = EvSwitch.Set(); - - int c = 0; - if (helper != null && c < waitTime) - { - while (Helper.signalHelperToExit) - { - Thread.Sleep(1); - } - - helper = null; - } - } - - internal static void Cleanup() - { - try - { - SendByeBye(); - - // UnhookClipboard(); - AskHelperThreadsToExit(500); - MainForm.NotifyIcon.Visible = false; - MainForm.NotifyIcon.Dispose(); - CloseAllFormsAndHooks(); - - DoSomethingInUIThread(() => - { - Sk?.Close(true); - }); - } - catch (Exception e) - { - Logger.Log(e); - } - } - - private static long lastReleaseAllKeysCall; - - internal static void ReleaseAllKeys() - { - if (Math.Abs(GetTick() - lastReleaseAllKeysCall) < 2000) - { - return; - } - - lastReleaseAllKeysCall = GetTick(); - - KEYBDDATA kd; - kd.dwFlags = (int)LLKHF.UP; - - VK[] keys = new VK[] - { - VK.LSHIFT, VK.LCONTROL, VK.LMENU, VK.LWIN, VK.RSHIFT, - VK.RCONTROL, VK.RMENU, VK.RWIN, VK.SHIFT, VK.MENU, VK.CONTROL, - }; - - Logger.LogDebug("***** ReleaseAllKeys has been called! *****:"); - - foreach (VK vk in keys) - { - if ((NativeMethods.GetAsyncKeyState((IntPtr)vk) & 0x8000) != 0) - { - Logger.LogDebug(vk.ToString() + " is down, release it..."); - Hook?.ResetLastSwitchKeys(); // Sticky key can turn ALL PC mode on (CtrlCtrlCtrl) - kd.wVk = (int)vk; - InputSimulation.SendKey(kd); - Hook?.ResetLastSwitchKeys(); - } - } - } - - private static void NetworkChange_NetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e) - { - Logger.LogDebug("NetworkAvailabilityEventArgs.IsAvailable: " + e.IsAvailable.ToString(CultureInfo.InvariantCulture)); - Helper.WndProcCounter++; - ScheduleReopenSocketsDueToNetworkChanges(!e.IsAvailable); - } - - private static void ScheduleReopenSocketsDueToNetworkChanges(bool closeSockets = true) - { - if (closeSockets) - { - // Slept/hibernated machine may still have the sockets' status as Connected:( (unchanged) so it would not re-connect after a timeout when waking up. - // Closing the sockets when it is going to sleep/hibernate will trigger the reconnection faster when it wakes up. - DoSomethingInUIThread( - () => - { - SocketStuff s = Sk; - Sk = null; - s?.Close(false); - }, - true); - } - - if (!Common.IsMyDesktopActive()) - { - PleaseReopenSocket = 0; - } - else if (PleaseReopenSocket != 10) - { - PleaseReopenSocket = 10; - } - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.WinAPI.cs b/src/modules/MouseWithoutBorders/App/Class/Common.WinAPI.cs index ed56101930..ee2d99398c 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Common.WinAPI.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Common.WinAPI.cs @@ -36,7 +36,7 @@ namespace MouseWithoutBorders internal static string ActiveDesktop => Common.activeDesktop; - private static void SystemEvents_DisplaySettingsChanged(object sender, EventArgs e) + internal static void SystemEvents_DisplaySettingsChanged(object sender, EventArgs e) { GetScreenConfig(); } @@ -340,7 +340,7 @@ namespace MouseWithoutBorders Setting.Values.LastX = JUST_GOT_BACK_FROM_SCREEN_SAVER; if (cleanupIfExit) { - Common.Cleanup(); + InitAndCleanup.Cleanup(); } Process.GetCurrentProcess().KillProcess(); diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.cs b/src/modules/MouseWithoutBorders/App/Class/Common.cs index 0494a952fd..ba5a1655e0 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Common.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Common.cs @@ -33,6 +33,7 @@ using MouseWithoutBorders.Class; using MouseWithoutBorders.Core; using MouseWithoutBorders.Exceptions; +using Clipboard = MouseWithoutBorders.Core.Clipboard; using Thread = MouseWithoutBorders.Core.Thread; // Log is enough @@ -90,8 +91,8 @@ namespace MouseWithoutBorders private static FrmMatrix matrixForm; private static FrmInputCallback inputCallbackForm; private static FrmAbout aboutForm; - private static Thread helper; #pragma warning disable SA1307 // Accessible fields should begin with upper-case letter + internal static Thread helper; internal static int screenWidth; internal static int screenHeight; #pragma warning restore SA1307 @@ -121,7 +122,9 @@ namespace MouseWithoutBorders internal static int switchCount; #pragma warning restore SA1307 private static long lastReconnectByHotKeyTime; - private static int tcpPort; +#pragma warning disable SA1307 // Accessible fields should begin with upper-case names + internal static int tcpPort; +#pragma warning restore SA1307 private static bool secondOpenSocketTry; private static string binaryName; @@ -210,7 +213,7 @@ namespace MouseWithoutBorders internal static bool Is64bitOS { - get; private set; + get; set; // set { Common.is64bitOS = value; } } @@ -611,7 +614,7 @@ namespace MouseWithoutBorders } * */ - private static void SendByeBye() + internal static void SendByeBye() { Logger.LogDebug($"{nameof(SendByeBye)}"); SendPackage(ID.ALL, PackageType.ByeBye); @@ -725,7 +728,7 @@ namespace MouseWithoutBorders internal static void SendImage(string machine, string file) { - LastDragDropFile = file; + Clipboard.LastDragDropFile = file; // Send ClipboardCapture if (machine.Equals("All", StringComparison.OrdinalIgnoreCase)) @@ -744,7 +747,7 @@ namespace MouseWithoutBorders internal static void SendImage(ID src, string file) { - LastDragDropFile = file; + Clipboard.LastDragDropFile = file; // Send ClipboardCapture SendPackage(src, PackageType.ClipboardCapture); @@ -1291,7 +1294,7 @@ namespace MouseWithoutBorders }); } - private static string GetMyStorageDir() + internal static string GetMyStorageDir() { string st = string.Empty; diff --git a/src/modules/MouseWithoutBorders/App/Class/IClipboardHelper.cs b/src/modules/MouseWithoutBorders/App/Class/IClipboardHelper.cs index 9a52f69529..62360b4795 100644 --- a/src/modules/MouseWithoutBorders/App/Class/IClipboardHelper.cs +++ b/src/modules/MouseWithoutBorders/App/Class/IClipboardHelper.cs @@ -28,6 +28,7 @@ using MouseWithoutBorders.Core; using SystemClipboard = System.Windows.Forms.Clipboard; #if !MM_HELPER +using Clipboard = MouseWithoutBorders.Core.Clipboard; using Thread = MouseWithoutBorders.Core.Thread; #endif @@ -159,7 +160,7 @@ namespace MouseWithoutBorders public void SendClipboardData(ByteArrayOrString data, bool isFilePath) { - _ = Common.CheckClipboardEx(data, isFilePath); + _ = Clipboard.CheckClipboardEx(data, isFilePath); } } #endif diff --git a/src/modules/MouseWithoutBorders/App/Class/InputHook.cs b/src/modules/MouseWithoutBorders/App/Class/InputHook.cs index 33cbe77e89..d68b1a1584 100644 --- a/src/modules/MouseWithoutBorders/App/Class/InputHook.cs +++ b/src/modules/MouseWithoutBorders/App/Class/InputHook.cs @@ -579,7 +579,7 @@ namespace MouseWithoutBorders.Class { Common.ShowToolTip("Reconnecting...", 2000); Common.LastReconnectByHotKeyTime = Common.GetTick(); - Common.PleaseReopenSocket = Common.REOPEN_WHEN_HOTKEY; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_HOTKEY; return false; } @@ -632,7 +632,7 @@ namespace MouseWithoutBorders.Class { // Common.DoSomethingInUIThread(delegate() { - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); } // ); diff --git a/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs b/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs index a991c7f64f..0bbd8014ae 100644 --- a/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs +++ b/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs @@ -407,7 +407,7 @@ namespace MouseWithoutBorders.Class { ResetModifiersState(Setting.Values.HotKeyLockMachine); eatKey = true; - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); _ = NativeMethods.LockWorkStation(); } } @@ -439,7 +439,7 @@ namespace MouseWithoutBorders.Class { ctrlDown = altDown = false; eatKey = true; - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); } break; @@ -449,7 +449,7 @@ namespace MouseWithoutBorders.Class { winDown = false; eatKey = true; - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); uint rv = NativeMethods.LockWorkStation(); Logger.LogDebug("LockWorkStation returned " + rv.ToString(CultureInfo.CurrentCulture)); } diff --git a/src/modules/MouseWithoutBorders/App/Class/Program.cs b/src/modules/MouseWithoutBorders/App/Class/Program.cs index 2fd8357e24..c139da46e9 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Program.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Program.cs @@ -235,7 +235,7 @@ namespace MouseWithoutBorders.Class _ = Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.SetCompatibleTextRenderingDefault(false); - Common.Init(); + InitAndCleanup.Init(); Core.Helper.WndProcCounter++; var formScreen = new FrmScreen(); @@ -314,7 +314,7 @@ namespace MouseWithoutBorders.Class MachineStuff.UpdateMachinePoolStringSetting(); SocketStuff.InvalidKeyFound = false; - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; Common.ReopenSockets(true); MachineStuff.SendMachineMatrix(); @@ -340,7 +340,7 @@ namespace MouseWithoutBorders.Class public void Reconnect() { SocketStuff.InvalidKeyFound = false; - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; Common.ReopenSockets(true); for (int i = 0; i < 10; i++) @@ -397,7 +397,7 @@ namespace MouseWithoutBorders.Class using var asyncFlowControl = ExecutionContext.SuppressFlow(); Common.InputCallbackThreadID = Thread.CurrentThread.ManagedThreadId; - while (!Common.InitDone) + while (!InitAndCleanup.InitDone) { Thread.Sleep(100); } diff --git a/src/modules/MouseWithoutBorders/App/Class/Setting.cs b/src/modules/MouseWithoutBorders/App/Class/Setting.cs index 30b99a97d0..623571f6ce 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Setting.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Setting.cs @@ -118,7 +118,7 @@ namespace MouseWithoutBorders.Class if (shouldReopenSockets) { SocketStuff.InvalidKeyFound = false; - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; Common.ReopenSockets(true); } @@ -1055,8 +1055,13 @@ namespace MouseWithoutBorders.Class if (machineId == 0) { - _properties.MachineID.Value = Common.Ran.Next(); - machineId = _properties.MachineID.Value; + var newMachineId = Common.Ran.Next(); + _properties.MachineID.Value = newMachineId; + machineId = newMachineId; + if (!PauseInstantSaving) + { + SaveSettings(); + } } } @@ -1068,6 +1073,11 @@ namespace MouseWithoutBorders.Class lock (_loadingSettingsLock) { _properties.MachineID.Value = value; + machineId = value; + if (!PauseInstantSaving) + { + SaveSettings(); + } } } } diff --git a/src/modules/MouseWithoutBorders/App/Class/SocketStuff.cs b/src/modules/MouseWithoutBorders/App/Class/SocketStuff.cs index 8796f61dfb..c5241beddf 100644 --- a/src/modules/MouseWithoutBorders/App/Class/SocketStuff.cs +++ b/src/modules/MouseWithoutBorders/App/Class/SocketStuff.cs @@ -29,6 +29,7 @@ using MouseWithoutBorders.Core; // using MouseWithoutBorders.Exceptions; +using Clipboard = MouseWithoutBorders.Core.Clipboard; using Thread = MouseWithoutBorders.Core.Thread; [module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.SocketStuff.#SendData(System.Byte[])", Justification = "Dotnet port with style preservation")] @@ -281,7 +282,7 @@ namespace MouseWithoutBorders.Class * */ Common.GetMachineName(); // IPs might have been changed - Common.UpdateMachineTimeAndID(); + InitAndCleanup.UpdateMachineTimeAndID(); Logger.LogDebug("Creating sockets..."); @@ -308,7 +309,7 @@ namespace MouseWithoutBorders.Class { Logger.TelemetryLogTrace("Restarting the service dues to WSAEADDRINUSE.", SeverityLevel.Warning); Program.StartService(); - Common.PleaseReopenSocket = Common.REOPEN_WHEN_WSAECONNRESET; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_WSAECONNRESET; } break; @@ -1248,7 +1249,7 @@ namespace MouseWithoutBorders.Class // WSAECONNRESET if (e is ExpectedSocketException se && se.ShouldReconnect) { - Common.PleaseReopenSocket = Common.REOPEN_WHEN_WSAECONNRESET; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_WSAECONNRESET; Logger.Log($"MainTCPRoutine: {nameof(FlagReopenSocketIfNeeded)}"); } } @@ -1306,7 +1307,7 @@ namespace MouseWithoutBorders.Class } catch (ObjectDisposedException e) { - Common.PleaseReopenSocket = Common.REOPEN_WHEN_WSAECONNRESET; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_WSAECONNRESET; UpdateTcpSockets(currentTcp, SocketStatus.ForceClosed); currentSocket.Close(); Logger.Log($"{nameof(MainTCPRoutine)}: The socket could have been closed/disposed by other threads: {e.Message}"); @@ -1353,10 +1354,10 @@ namespace MouseWithoutBorders.Class * In this case, we should give ONE try to reconnect. */ - if (Common.ReopenSocketDueToReadError) + if (InitAndCleanup.ReopenSocketDueToReadError) { - Common.PleaseReopenSocket = Common.REOPEN_WHEN_WSAECONNRESET; - Common.ReopenSocketDueToReadError = false; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_WSAECONNRESET; + InitAndCleanup.ReopenSocketDueToReadError = false; } break; @@ -1641,7 +1642,7 @@ namespace MouseWithoutBorders.Class bool clientPushData = true; ClipboardPostAction postAction = ClipboardPostAction.Other; - bool handShaken = Common.ShakeHand(ref remoteEndPoint, s, out Stream enStream, out Stream deStream, ref clientPushData, ref postAction); + bool handShaken = Clipboard.ShakeHand(ref remoteEndPoint, s, out Stream enStream, out Stream deStream, ref clientPushData, ref postAction); if (!handShaken) { @@ -1656,7 +1657,7 @@ namespace MouseWithoutBorders.Class if (clientPushData) { - Common.ReceiveAndProcessClipboardData(remoteEndPoint, s, enStream, deStream, $"{postAction}"); + Clipboard.ReceiveAndProcessClipboardData(remoteEndPoint, s, enStream, deStream, $"{postAction}"); } else { @@ -1680,23 +1681,23 @@ namespace MouseWithoutBorders.Class const int CLOSE_TIMEOUT = 10; byte[] header = new byte[1024]; string headerString = string.Empty; - if (Common.LastDragDropFile != null) + if (Clipboard.LastDragDropFile != null) { string fileName = null; if (!Launch.ImpersonateLoggedOnUserAndDoSomething(() => { - if (!File.Exists(Common.LastDragDropFile)) + if (!File.Exists(Clipboard.LastDragDropFile)) { - headerString = Directory.Exists(Common.LastDragDropFile) - ? $"{0}*{Common.LastDragDropFile} - Folder is not supported, zip it first!" - : Common.LastDragDropFile.Contains("- File too big") - ? $"{0}*{Common.LastDragDropFile}" - : $"{0}*{Common.LastDragDropFile} not found!"; + headerString = Directory.Exists(Clipboard.LastDragDropFile) + ? $"{0}*{Clipboard.LastDragDropFile} - Folder is not supported, zip it first!" + : Clipboard.LastDragDropFile.Contains("- File too big") + ? $"{0}*{Clipboard.LastDragDropFile}" + : $"{0}*{Clipboard.LastDragDropFile} not found!"; } else { - fileName = Common.LastDragDropFile; + fileName = Clipboard.LastDragDropFile; headerString = $"{new FileInfo(fileName).Length}*{fileName}"; } })) @@ -1739,11 +1740,11 @@ namespace MouseWithoutBorders.Class Logger.Log(log); } } - else if (!Common.IsClipboardDataImage && Common.LastClipboardData != null) + else if (!Clipboard.IsClipboardDataImage && Clipboard.LastClipboardData != null) { try { - byte[] data = Common.LastClipboardData; + byte[] data = Clipboard.LastClipboardData; headerString = $"{data.Length}*{"text"}"; Common.GetBytesU(headerString).CopyTo(header, 0); @@ -1773,9 +1774,9 @@ namespace MouseWithoutBorders.Class Logger.Log(log); } } - else if (Common.LastClipboardData != null && Common.LastClipboardData.Length > 0) + else if (Clipboard.LastClipboardData != null && Clipboard.LastClipboardData.Length > 0) { - byte[] data = Common.LastClipboardData; + byte[] data = Clipboard.LastClipboardData; headerString = $"{data.Length}*{"image"}"; Common.GetBytesU(headerString).CopyTo(header, 0); @@ -1984,8 +1985,8 @@ namespace MouseWithoutBorders.Class { tcp = null; Setting.Values.MachineId = Common.Ran.Next(); - Common.UpdateMachineTimeAndID(); - Common.PleaseReopenSocket = Common.REOPEN_WHEN_HOTKEY; + InitAndCleanup.UpdateMachineTimeAndID(); + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_HOTKEY; Logger.TelemetryLogTrace("MachineID conflict.", SeverityLevel.Information); } diff --git a/src/modules/MouseWithoutBorders/App/Core/Clipboard.cs b/src/modules/MouseWithoutBorders/App/Core/Clipboard.cs new file mode 100644 index 0000000000..5840325941 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/Clipboard.cs @@ -0,0 +1,1155 @@ +// Copyright (c) Microsoft 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.Specialized; +using System.Diagnostics; +using System.Drawing; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; + +using Microsoft.PowerToys.Telemetry; +using MouseWithoutBorders.Class; +using MouseWithoutBorders.Exceptions; + +using SystemClipboard = System.Windows.Forms.Clipboard; + +// +// Clipboard related routines. +// +// +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// +namespace MouseWithoutBorders.Core; + +internal static class Clipboard +{ + private static readonly char[] Comma = new char[] { ',' }; + private static readonly char[] Star = new char[] { '*' }; + private static readonly char[] NullSeparator = new char[] { '\0' }; + + internal const uint BIG_CLIPBOARD_DATA_TIMEOUT = 30000; + private const uint MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP = 1024 * 1024; // 1MB + private const uint MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT = 100 * 1024 * 1024; // 100MB + private const int TEXT_HEADER_SIZE = 12; + private const int DATA_SIZE = 48; + private const string TEXT_TYPE_SEP = "{4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F}"; + private static long lastClipboardEventTime; + private static string lastMachineWithClipboardData; + private static string lastDragDropFile; +#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter + internal static long clipboardCopiedTime; +#pragma warning restore SA1307 + + internal static ID LastIDWithClipboardData { get; set; } + + internal static string LastDragDropFile + { + get => Clipboard.lastDragDropFile; + set => Clipboard.lastDragDropFile = value; + } + + internal static string LastMachineWithClipboardData + { + get => Clipboard.lastMachineWithClipboardData; + set => Clipboard.lastMachineWithClipboardData = value; + } + + private static long LastClipboardEventTime + { + get => Clipboard.lastClipboardEventTime; + set => Clipboard.lastClipboardEventTime = value; + } + + private static IntPtr NextClipboardViewer { get; set; } + + internal static bool IsClipboardDataImage { get; private set; } + + internal static byte[] LastClipboardData { get; private set; } + + private static object lastClipboardObject = string.Empty; + + internal static bool HasSwitchedMachineSinceLastCopy { get; set; } + + internal static bool CheckClipboardEx(ByteArrayOrString data, bool isFilePath) + { + Logger.LogDebug($"{nameof(CheckClipboardEx)}: ShareClipboard = {Setting.Values.ShareClipboard}, TransferFile = {Setting.Values.TransferFile}, data = {data}."); + Logger.LogDebug($"{nameof(CheckClipboardEx)}: {nameof(Setting.Values.OneWayClipboardMode)} = {Setting.Values.OneWayClipboardMode}."); + + if (!Setting.Values.ShareClipboard) + { + return false; + } + + if (Common.RunWithNoAdminRight && Setting.Values.OneWayClipboardMode) + { + return false; + } + + if (Common.GetTick() - LastClipboardEventTime < 1000) + { + Logger.LogDebug("GetTick() - lastClipboardEventTime < 1000"); + LastClipboardEventTime = Common.GetTick(); + return false; + } + + LastClipboardEventTime = Common.GetTick(); + + try + { + IsClipboardDataImage = false; + LastClipboardData = null; + LastDragDropFile = null; + GC.Collect(); + + string stringData = null; + byte[] byteData = null; + + if (data.IsByteArray) + { + byteData = data.GetByteArray(); + } + else + { + stringData = data.GetString(); + } + + if (stringData != null) + { + if (!HasSwitchedMachineSinceLastCopy) + { + if (lastClipboardObject is string lastStringData && lastStringData.Equals(stringData, StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("CheckClipboardEx: Same string data."); + return false; + } + } + + HasSwitchedMachineSinceLastCopy = false; + + if (isFilePath) + { + Logger.LogDebug("Clipboard contains FileDropList"); + + if (!Setting.Values.TransferFile) + { + Logger.LogDebug("TransferFile option is unchecked."); + return false; + } + + string filePath = stringData; + + _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => + { + if (File.Exists(filePath) || Directory.Exists(filePath)) + { + if (File.Exists(filePath) && new FileInfo(filePath).Length <= MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT) + { + Logger.LogDebug("Clipboard contains: " + filePath); + LastDragDropFile = filePath; + Common.SendClipboardBeat(); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_BIG_CLIPBOARD, -1, Common.ICON_BIG_CLIPBOARD, -1 }); + } + else + { + if (Directory.Exists(filePath)) + { + Logger.LogDebug("Clipboard contains a directory: " + filePath); + LastDragDropFile = filePath; + Common.SendClipboardBeat(); + } + else + { + LastDragDropFile = filePath + " - File too big (greater than 100MB), please drag and drop the file instead!"; + Common.SendClipboardBeat(); + Logger.Log("Clipboard: File too big: " + filePath); + } + + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_ERROR, -1, Common.ICON_ERROR, -1 }); + } + } + else + { + Logger.Log("CheckClipboardEx: File not found: " + filePath); + } + }); + } + else + { + byte[] texts = Common.GetBytesU(stringData); + + using MemoryStream ms = new(); + using (DeflateStream s = new(ms, CompressionMode.Compress, true)) + { + s.Write(texts, 0, texts.Length); + } + + Logger.LogDebug("Plain/Zip = " + texts.Length.ToString(CultureInfo.CurrentCulture) + "/" + + ms.Length.ToString(CultureInfo.CurrentCulture)); + + LastClipboardData = ms.GetBuffer(); + } + } + else if (byteData != null) + { + if (!HasSwitchedMachineSinceLastCopy) + { + if (lastClipboardObject is byte[] lastByteData && Enumerable.SequenceEqual(lastByteData, byteData)) + { + Logger.LogDebug("CheckClipboardEx: Same byte[] data."); + return false; + } + } + + HasSwitchedMachineSinceLastCopy = false; + + Logger.LogDebug("Clipboard contains image"); + IsClipboardDataImage = true; + LastClipboardData = byteData; + } + else + { + Logger.LogDebug("*** Clipboard contains something else!"); + return false; + } + + lastClipboardObject = data; + + if (LastClipboardData != null && LastClipboardData.Length > 0) + { + if (LastClipboardData.Length > MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP) + { + Common.SendClipboardBeat(); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_BIG_CLIPBOARD, -1, Common.ICON_BIG_CLIPBOARD, -1 }); + } + else + { + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_SMALL_CLIPBOARD, -1, -1, -1 }); + SendClipboardDataUsingTCP(LastClipboardData, IsClipboardDataImage); + } + + return true; + } + } + catch (Exception e) + { + Logger.Log(e); + } + + return false; + } + + private static void SendClipboardDataUsingTCP(byte[] bytes, bool image) + { + if (Common.Sk == null) + { + return; + } + + new Task(() => + { + // SuppressFlow fixes an issue on service mode, where the helper process can't get enough permissions to be started again. + // More details can be found on: https://github.com/microsoft/PowerToys/pull/36892 + using var asyncFlowControl = ExecutionContext.SuppressFlow(); + + System.Threading.Thread thread = Thread.CurrentThread; + thread.Name = $"{nameof(SendClipboardDataUsingTCP)}.{thread.ManagedThreadId}"; + Thread.UpdateThreads(thread); + int l = bytes.Length; + int index = 0; + int len; + DATA package = new(); + byte[] buf = new byte[Common.PACKAGE_SIZE_EX]; + int dataStart = Common.PACKAGE_SIZE_EX - DATA_SIZE; + + while (true) + { + if ((index + DATA_SIZE) > l) + { + len = l - index; + Array.Clear(buf, 0, Common.PACKAGE_SIZE_EX); + } + else + { + len = DATA_SIZE; + } + + Array.Copy(bytes, index, buf, dataStart, len); + package.Bytes = buf; + + package.Type = image ? PackageType.ClipboardImage : PackageType.ClipboardText; + package.Des = ID.ALL; + Common.SkSend(package, (uint)Common.MachineID, false); + + index += DATA_SIZE; + if (index >= l) + { + break; + } + } + + package.Type = PackageType.ClipboardDataEnd; + package.Des = ID.ALL; + Common.SkSend(package, (uint)Common.MachineID, false); + }).Start(); + } + + internal static void ReceiveClipboardDataUsingTCP(DATA data, bool image, TcpSk tcp) + { + try + { + if (Common.Sk == null || Common.RunOnLogonDesktop || Common.RunOnScrSaverDesktop) + { + return; + } + + MemoryStream m = new(); + int dataStart = Common.PACKAGE_SIZE_EX - DATA_SIZE; + m.Write(data.Bytes, dataStart, DATA_SIZE); + int unexpectedCount = 0; + + bool done = false; + do + { + data = SocketStuff.TcpReceiveData(tcp, out int err); + + switch (data.Type) + { + case PackageType.ClipboardImage: + case PackageType.ClipboardText: + m.Write(data.Bytes, dataStart, DATA_SIZE); + break; + + case PackageType.ClipboardDataEnd: + done = true; + break; + + default: + Receiver.ProcessPackage(data, tcp); + if (++unexpectedCount > 100) + { + Logger.Log("ReceiveClipboardDataUsingTCP: unexpectedCount > 100!"); + done = true; + } + + break; + } + } + while (!done); + + LastClipboardEventTime = Common.GetTick(); + + if (image) + { + Image im = Image.FromStream(m); + Clipboard.SetImage(im); + LastClipboardEventTime = Common.GetTick(); + } + else + { + Clipboard.SetClipboardData(m.GetBuffer()); + LastClipboardEventTime = Common.GetTick(); + } + + m.Dispose(); + + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_SMALL_CLIPBOARD, -1, Common.ICON_SMALL_CLIPBOARD, -1 }); + } + catch (Exception e) + { + Logger.Log("ReceiveClipboardDataUsingTCP: " + e.Message); + } + } + + private static readonly Lock ClipboardThreadOldLock = new(); + private static System.Threading.Thread clipboardThreadOld; + + internal static void GetRemoteClipboard(string postAction) + { + if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) + { + if (Clipboard.LastMachineWithClipboardData == null || + Clipboard.LastMachineWithClipboardData.Length < 1) + { + return; + } + + new Task(() => + { + // SuppressFlow fixes an issue on service mode, where the helper process can't get enough permissions to be started again. + // More details can be found on: https://github.com/microsoft/PowerToys/pull/36892 + using var asyncFlowControl = ExecutionContext.SuppressFlow(); + + System.Threading.Thread thread = Thread.CurrentThread; + thread.Name = $"{nameof(ConnectAndGetData)}.{thread.ManagedThreadId}"; + Thread.UpdateThreads(thread); + ConnectAndGetData(postAction); + }).Start(); + } + } + + private static Stream m; + + private static void ConnectAndGetData(object postAction) + { + if (Common.Sk == null) + { + Logger.Log("ConnectAndGetData: Sk == null!"); + return; + } + + string remoteMachine; + TcpClient clipboardTcpClient = null; + string postAct = (string)postAction; + + Logger.LogDebug("ConnectAndGetData.postAction: " + postAct); + + ClipboardPostAction clipboardPostAct = postAct.Contains("mspaint,") ? ClipboardPostAction.Mspaint + : postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase) ? ClipboardPostAction.Desktop + : ClipboardPostAction.Other; + + try + { + remoteMachine = postAct.Contains("mspaint,") ? postAct.Split(Comma)[1] : Clipboard.LastMachineWithClipboardData; + + remoteMachine = remoteMachine.Trim(); + + if (!Common.IsConnectedByAClientSocketTo(remoteMachine)) + { + Logger.Log($"No potential inbound connection from {Common.MachineName} to {remoteMachine}, ask for a push back instead."); + ID machineId = MachineStuff.MachinePool.ResolveID(remoteMachine); + + if (machineId != ID.NONE) + { + Common.SkSend( + new DATA() + { + Type = PackageType.ClipboardAsk, + Des = machineId, + MachineName = Common.MachineName, + PostAction = clipboardPostAct, + }, + null, + false); + } + else + { + Logger.Log($"Unable to resolve {remoteMachine} to its long IP."); + } + + return; + } + + Common.ShowToolTip("Connecting to " + remoteMachine, 2000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); + + clipboardTcpClient = ConnectToRemoteClipboardSocket(remoteMachine); + } + catch (ThreadAbortException) + { + Logger.Log("The current thread is being aborted (1)."); + if (clipboardTcpClient != null && clipboardTcpClient.Connected) + { + clipboardTcpClient.Client.Close(); + } + + return; + } + catch (Exception e) + { + Logger.Log(e); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, Common.ICON_BIG_CLIPBOARD, -1, + }); + Common.ShowToolTip(e.Message, 1000, ToolTipIcon.Warning, Setting.Values.ShowClipNetStatus); + return; + } + + bool clientPushData = false; + + if (!ShakeHand(ref remoteMachine, clipboardTcpClient.Client, out Stream enStream, out Stream deStream, ref clientPushData, ref clipboardPostAct)) + { + return; + } + + ReceiveAndProcessClipboardData(remoteMachine, clipboardTcpClient.Client, enStream, deStream, postAct); + } + + internal static void ReceiveAndProcessClipboardData(string remoteMachine, Socket s, Stream enStream, Stream deStream, string postAct) + { + lock (ClipboardThreadOldLock) + { + // Do not enable two connections at the same time. + if (clipboardThreadOld != null && clipboardThreadOld.ThreadState != System.Threading.ThreadState.AbortRequested + && clipboardThreadOld.ThreadState != System.Threading.ThreadState.Aborted && clipboardThreadOld.IsAlive + && clipboardThreadOld.ManagedThreadId != Thread.CurrentThread.ManagedThreadId) + { + if (clipboardThreadOld.Join(3000)) + { + if (m != null) + { + m.Flush(); + m.Close(); + m = null; + } + } + } + + clipboardThreadOld = Thread.CurrentThread; + } + + try + { + byte[] header = new byte[1024]; + byte[] buf = new byte[Common.NETWORK_STREAM_BUF_SIZE]; + string fileName = null; + string tempFile = "data", savingFolder = string.Empty; + Common.ToggleIconsIndex = 0; + int rv; + long receivedCount = 0; + + if ((rv = deStream.ReadEx(header, 0, header.Length)) < header.Length) + { + Logger.Log("Reading header failed: " + rv.ToString(CultureInfo.CurrentCulture)); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, -1, -1, + }); + return; + } + + fileName = Common.GetStringU(header).Replace("\0", string.Empty); + Logger.LogDebug("Header: " + fileName); + string[] headers = fileName.Split(Star); + + if (headers.Length < 2 || !long.TryParse(headers[0], out long dataSize)) + { + Logger.Log(string.Format( + CultureInfo.CurrentCulture, + "Reading header failed: {0}:{1}", + headers.Length, + fileName)); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, -1, -1, + }); + return; + } + + fileName = headers[1]; + + Logger.LogDebug(string.Format( + CultureInfo.CurrentCulture, + "Receiving {0}:{1} from {2}...", + Path.GetFileName(fileName), + dataSize, + remoteMachine)); + Common.ShowToolTip( + string.Format( + CultureInfo.CurrentCulture, + "Receiving {0} from {1}...", + Path.GetFileName(fileName), + remoteMachine), + 5000, + ToolTipIcon.Info, + Setting.Values.ShowClipNetStatus); + if (fileName.StartsWith("image", StringComparison.CurrentCultureIgnoreCase) || + fileName.StartsWith("text", StringComparison.CurrentCultureIgnoreCase)) + { + m = new MemoryStream(); + } + else + { + if (postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase)) + { + _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => + { + savingFolder = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + "\\MouseWithoutBorders\\"; + + if (!Directory.Exists(savingFolder)) + { + _ = Directory.CreateDirectory(savingFolder); + } + }); + + tempFile = savingFolder + Path.GetFileName(fileName); + m = new FileStream(tempFile, FileMode.Create); + } + else if (postAct.Contains("mspaint")) + { + tempFile = Common.GetMyStorageDir() + @"ScreenCapture-" + + remoteMachine + ".png"; + m = new FileStream(tempFile, FileMode.Create); + } + else + { + tempFile = Common.GetMyStorageDir(); + tempFile += Path.GetFileName(fileName); + m = new FileStream(tempFile, FileMode.Create); + } + + Logger.Log("==> " + tempFile); + } + + Common.ShowToolTip( + string.Format( + CultureInfo.CurrentCulture, + "Receiving {0} from {1}...", + Path.GetFileName(fileName), + remoteMachine), + 5000, + ToolTipIcon.Info, + Setting.Values.ShowClipNetStatus); + + do + { + rv = deStream.ReadEx(buf, 0, buf.Length); + + if (rv > 0) + { + receivedCount += rv; + + if (receivedCount > dataSize) + { + rv -= (int)(receivedCount - dataSize); + } + + m.Write(buf, 0, rv); + } + + if (Common.ToggleIcons == null) + { + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_SMALL_CLIPBOARD, + -1, Common.ICON_SMALL_CLIPBOARD, -1, + }); + } + + string text = string.Format(CultureInfo.CurrentCulture, "{0}KB received: {1}", m.Length / 1024, Path.GetFileName(fileName)); + + Common.DoSomethingInUIThread(() => + { + Common.MainForm.SetTrayIconText(text); + }); + } + while (rv > 0); + + if (m != null && fileName != null) + { + m.Flush(); + Logger.LogDebug(m.Length.ToString(CultureInfo.CurrentCulture) + " bytes received."); + Clipboard.LastClipboardEventTime = Common.GetTick(); + string toolTipText = null; + string sizeText = m.Length >= 1024 + ? (m.Length / 1024).ToString(CultureInfo.CurrentCulture) + "KB" + : m.Length.ToString(CultureInfo.CurrentCulture) + "Bytes"; + + PowerToysTelemetry.Log.WriteEvent(new MouseWithoutBorders.Telemetry.MouseWithoutBordersClipboardFileTransferEvent()); + + if (fileName.StartsWith("image", StringComparison.CurrentCultureIgnoreCase)) + { + Clipboard.SetImage(Image.FromStream(m)); + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} from {2} is in Clipboard.", + sizeText, + "image", + remoteMachine); + } + else if (fileName.StartsWith("text", StringComparison.CurrentCultureIgnoreCase)) + { + byte[] data = (m as MemoryStream).GetBuffer(); + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} from {2} is in Clipboard.", + sizeText, + "text", + remoteMachine); + Clipboard.SetClipboardData(data); + } + else if (tempFile != null) + { + if (postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase)) + { + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} received from {2}!", + sizeText, + Path.GetFileName(fileName), + remoteMachine); + + _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => + { + ProcessStartInfo startInfo = new(); + startInfo.UseShellExecute = true; + startInfo.WorkingDirectory = savingFolder; + startInfo.FileName = savingFolder; + startInfo.Verb = "open"; + _ = Process.Start(startInfo); + }); + } + else if (postAct.Contains("mspaint")) + { + m.Close(); + m = null; + Common.OpenImage(tempFile); + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} from {2} is in Mspaint.", + sizeText, + Path.GetFileName(tempFile), + remoteMachine); + } + else + { + StringCollection filePaths = new() + { + tempFile, + }; + Clipboard.SetFileDropList(filePaths); + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} from {2} is in Clipboard.", + sizeText, + Path.GetFileName(fileName), + remoteMachine); + } + } + + if (!string.IsNullOrWhiteSpace(toolTipText)) + { + Common.ShowToolTip(toolTipText, 5000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); + } + + Common.DoSomethingInUIThread(() => + { + Common.MainForm.UpdateNotifyIcon(); + }); + + m?.Close(); + m = null; + } + } + catch (ThreadAbortException) + { + Logger.Log("The current thread is being aborted (3)."); + s.Close(); + + if (m != null) + { + m.Close(); + m = null; + } + + return; + } + catch (Exception e) + { + if (e is IOException) + { + string log = $"{nameof(ReceiveAndProcessClipboardData)}: Exception accessing the socket: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; + Logger.Log(log); + } + else + { + Logger.Log(e); + } + + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, Common.ICON_BIG_CLIPBOARD, -1, + }); + Common.ShowToolTip(e.Message, 1000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); + + if (m != null) + { + m.Close(); + m = null; + } + + return; + } + + s.Close(); + } + + internal static bool ShakeHand(ref string remoteName, Socket s, out Stream enStream, out Stream deStream, ref bool clientPushData, ref ClipboardPostAction postAction) + { + const int CLIPBOARD_HANDSHAKE_TIMEOUT = 30; + s.ReceiveTimeout = CLIPBOARD_HANDSHAKE_TIMEOUT * 1000; + s.NoDelay = true; + s.SendBufferSize = s.ReceiveBufferSize = 1024000; + + bool handShaken = false; + enStream = deStream = null; + + try + { + DATA package = new() + { + Type = clientPushData ? PackageType.ClipboardPush : PackageType.Clipboard, + PostAction = postAction, + Src = Common.MachineID, + MachineName = Common.MachineName, + }; + + byte[] buf = new byte[Common.PACKAGE_SIZE_EX]; + + NetworkStream ns = new(s); + enStream = Common.GetEncryptedStream(ns); + Common.SendOrReceiveARandomDataBlockPerInitialIV(enStream); + Logger.LogDebug($"{nameof(ShakeHand)}: Writing header package."); + enStream.Write(package.Bytes, 0, Common.PACKAGE_SIZE_EX); + + Logger.LogDebug($"{nameof(ShakeHand)}: Sent: clientPush={clientPushData}, postAction={postAction}."); + + deStream = Common.GetDecryptedStream(ns); + Common.SendOrReceiveARandomDataBlockPerInitialIV(deStream, false); + + Logger.LogDebug($"{nameof(ShakeHand)}: Reading header package."); + + int bytesReceived = deStream.ReadEx(buf, 0, Common.PACKAGE_SIZE_EX); + package.Bytes = buf; + + string name = "Unknown"; + + if (bytesReceived == Common.PACKAGE_SIZE_EX) + { + if (package.Type is PackageType.Clipboard or PackageType.ClipboardPush) + { + name = remoteName = package.MachineName; + + Logger.LogDebug($"{nameof(ShakeHand)}: Connection from {name}:{package.Src}"); + + if (MachineStuff.MachinePool.ResolveID(name) == package.Src && Common.IsConnectedTo(package.Src)) + { + clientPushData = package.Type == PackageType.ClipboardPush; + postAction = package.PostAction; + handShaken = true; + Logger.LogDebug($"{nameof(ShakeHand)}: Received: clientPush={clientPushData}, postAction={postAction}."); + } + else + { + Logger.LogDebug($"{nameof(ShakeHand)}: No active connection to the machine: {name}."); + } + } + else + { + Logger.LogDebug($"{nameof(ShakeHand)}: Unexpected package type: {package.Type}."); + } + } + else + { + Logger.LogDebug($"{nameof(ShakeHand)}: BytesTransferred != PACKAGE_SIZE_EX: {bytesReceived}"); + } + + if (!handShaken) + { + string msg = $"Clipboard connection rejected: {name}:{remoteName}/{package.Src}\r\n\r\nMake sure you run the same version in all machines."; + Logger.Log(msg); + Common.ShowToolTip(msg, 3000, ToolTipIcon.Warning); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_BIG_CLIPBOARD, -1, -1, -1 }); + } + } + catch (ThreadAbortException) + { + Logger.Log($"{nameof(ShakeHand)}: The current thread is being aborted."); + s.Close(); + } + catch (Exception e) + { + if (e is IOException) + { + string log = $"{nameof(ShakeHand)}: Exception accessing the socket: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; + Logger.Log(log); + } + else + { + Logger.Log(e); + } + + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, Common.ICON_BIG_CLIPBOARD, -1, + }); + Common.MainForm.UpdateNotifyIcon(); + Common.ShowToolTip(e.Message + "\r\n\r\nMake sure you run the same version in all machines.", 1000, ToolTipIcon.Warning, Setting.Values.ShowClipNetStatus); + + if (m != null) + { + m.Close(); + m = null; + } + } + + return handShaken; + } + + internal static TcpClient ConnectToRemoteClipboardSocket(string remoteMachine) + { + TcpClient clipboardTcpClient; + clipboardTcpClient = new TcpClient(AddressFamily.InterNetworkV6); + clipboardTcpClient.Client.DualMode = true; + + SocketStuff sk = Common.Sk; + + if (sk != null) + { + Common.DoSomethingInUIThread(() => Common.MainForm.ChangeIcon(Common.ICON_SMALL_CLIPBOARD)); + + System.Net.IPAddress ip = Common.GetConnectedClientSocketIPAddressFor(remoteMachine); + Logger.LogDebug($"{nameof(ConnectToRemoteClipboardSocket)}Connecting to {remoteMachine}:{ip}:{sk.TcpPort}..."); + + if (ip != null) + { + clipboardTcpClient.Connect(ip, sk.TcpPort); + } + else + { + clipboardTcpClient.Connect(remoteMachine, sk.TcpPort); + } + + Logger.LogDebug($"Connected from {clipboardTcpClient.Client.LocalEndPoint}. Getting data..."); + return clipboardTcpClient; + } + else + { + throw new ExpectedSocketException($"{nameof(ConnectToRemoteClipboardSocket)}: No longer connected."); + } + } + + private static void SetClipboardData(byte[] data) + { + if (data == null || data.Length <= 0) + { + Logger.Log("data is null or empty!"); + return; + } + + if (data.Length > 1024000) + { + Common.ShowToolTip( + string.Format( + CultureInfo.CurrentCulture, + "Decompressing {0} clipboard data ...", + (data.Length / 1024).ToString(CultureInfo.CurrentCulture) + "KB"), + 5000, + ToolTipIcon.Info, + Setting.Values.ShowClipNetStatus); + } + + string st = string.Empty; + + using (MemoryStream ms = new(data)) + { + using DeflateStream s = new(ms, CompressionMode.Decompress, true); + const int BufferSize = 1024000; // Buffer size should be big enough, this is critical to performance! + + int rv = 0; + + do + { + byte[] buffer = new byte[BufferSize]; + rv = s.ReadEx(buffer, 0, BufferSize); + + if (rv > 0) + { + st += Common.GetStringU(buffer); + } + else + { + break; + } + } + while (true); + } + + int textTypeCount = 0; + string[] texts = st.Split(new string[] { TEXT_TYPE_SEP }, StringSplitOptions.RemoveEmptyEntries); + string tmp; + DataObject data1 = new(); + + foreach (string txt in texts) + { + if (string.IsNullOrEmpty(txt.Trim(NullSeparator))) + { + continue; + } + + tmp = txt[3..]; + + if (txt.StartsWith("RTF", StringComparison.CurrentCultureIgnoreCase)) + { + Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of RTF <-"); + data1.SetData(DataFormats.Rtf, tmp); + } + else if (txt.StartsWith("HTM", StringComparison.CurrentCultureIgnoreCase)) + { + Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of HTM <-"); + data1.SetData(DataFormats.Html, tmp); + } + else if (txt.StartsWith("TXT", StringComparison.CurrentCultureIgnoreCase)) + { + Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of TXT <-"); + data1.SetData(DataFormats.UnicodeText, tmp); + } + else + { + if (textTypeCount == 0) + { + Logger.LogDebug(((double)txt.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of UNI <-"); + data1.SetData(DataFormats.UnicodeText, txt); + } + + Logger.Log("Invalid clipboard format received!"); + } + + textTypeCount++; + } + + if (textTypeCount > 0) + { + Clipboard.SetDataObject(data1); + } + } + + private static void SetFileDropList(StringCollection filePaths) + { + Common.DoSomethingInUIThread(() => + { + try + { + _ = Common.Retry( + nameof(SystemClipboard.SetFileDropList), + () => + { + SystemClipboard.SetFileDropList(filePaths); + return true; + }, + (log) => Logger.TelemetryLogTrace( + log, + SeverityLevel.Information), + () => Clipboard.LastClipboardEventTime = Common.GetTick()); + } + catch (ExternalException e) + { + Logger.Log(e); + } + catch (ThreadStateException e) + { + Logger.Log(e); + } + catch (ArgumentNullException e) + { + Logger.Log(e); + } + catch (ArgumentException e) + { + Logger.Log(e); + } + }); + } + + private static void SetImage(Image image) + { + Common.DoSomethingInUIThread(() => + { + try + { + _ = Common.Retry( + nameof(SystemClipboard.SetImage), + () => + { + SystemClipboard.SetImage(image); + return true; + }, + (log) => Logger.TelemetryLogTrace(log, SeverityLevel.Information), + () => Clipboard.LastClipboardEventTime = Common.GetTick()); + } + catch (ExternalException e) + { + Logger.Log(e); + } + catch (ThreadStateException e) + { + Logger.Log(e); + } + catch (ArgumentNullException e) + { + Logger.Log(e); + } + }); + } + + internal static void SetText(string text) + { + Common.DoSomethingInUIThread(() => + { + try + { + _ = Common.Retry( + nameof(SystemClipboard.SetText), + () => + { + SystemClipboard.SetText(text); + return true; + }, + (log) => Logger.TelemetryLogTrace(log, SeverityLevel.Information), + () => Clipboard.LastClipboardEventTime = Common.GetTick()); + } + catch (ExternalException e) + { + Logger.Log(e); + } + catch (ThreadStateException e) + { + Logger.Log(e); + } + catch (ArgumentNullException e) + { + Logger.Log(e); + } + }); + } + + private static void SetDataObject(DataObject dataObject) + { + Common.DoSomethingInUIThread(() => + { + try + { + SystemClipboard.SetDataObject(dataObject, true, 10, 200); + } + catch (ExternalException e) + { + string dataFormats = string.Join(",", dataObject.GetFormats()); + Logger.Log($"{e.Message}: {dataFormats}"); + } + catch (ThreadStateException e) + { + Logger.Log(e); + } + catch (ArgumentNullException e) + { + Logger.Log(e); + } + }); + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/DragDrop.cs b/src/modules/MouseWithoutBorders/App/Core/DragDrop.cs index 6d13672d3a..2decb83261 100644 --- a/src/modules/MouseWithoutBorders/App/Core/DragDrop.cs +++ b/src/modules/MouseWithoutBorders/App/Core/DragDrop.cs @@ -83,7 +83,7 @@ internal static class DragDrop if (wParam == Common.WM_RBUTTONUP && IsDropping) { IsDropping = false; - Common.LastIDWithClipboardData = ID.NONE; + Clipboard.LastIDWithClipboardData = ID.NONE; } } @@ -193,7 +193,7 @@ internal static class DragDrop { if (!string.IsNullOrEmpty(dragFileName) && (File.Exists(dragFileName) || Directory.Exists(dragFileName))) { - Common.LastDragDropFile = dragFileName; + Clipboard.LastDragDropFile = dragFileName; /* * possibleDropMachineID is used as desID sent in DragDropStep06(); * */ @@ -270,7 +270,7 @@ internal static class DragDrop else { IsDragging = false; - Common.LastIDWithClipboardData = ID.NONE; + Clipboard.LastIDWithClipboardData = ID.NONE; } } } @@ -280,7 +280,7 @@ internal static class DragDrop Logger.LogDebug("DragDropStep10: Hide the form and get data..."); IsDropping = false; IsDragging = false; - Common.LastIDWithClipboardData = ID.NONE; + Clipboard.LastIDWithClipboardData = ID.NONE; Common.DoSomethingInUIThread(() => { @@ -288,7 +288,7 @@ internal static class DragDrop }); PowerToysTelemetry.Log.WriteEvent(new MouseWithoutBorders.Telemetry.MouseWithoutBordersDragAndDropEvent()); - Common.GetRemoteClipboard("desktop"); + Clipboard.GetRemoteClipboard("desktop"); } internal static void DragDropStep11() @@ -298,8 +298,8 @@ internal static class DragDrop IsDropping = false; IsDragging = false; DragMachine = (ID)1; - Common.LastIDWithClipboardData = ID.NONE; - Common.LastDragDropFile = null; + Clipboard.LastIDWithClipboardData = ID.NONE; + Clipboard.LastDragDropFile = null; MouseDown = false; } @@ -307,7 +307,7 @@ internal static class DragDrop { Logger.LogDebug("DragDropStep12: ClipboardDragDropEnd received"); IsDropping = false; - Common.LastIDWithClipboardData = ID.NONE; + Clipboard.LastIDWithClipboardData = ID.NONE; Common.DoSomethingInUIThread(() => { diff --git a/src/modules/MouseWithoutBorders/App/Core/Event.cs b/src/modules/MouseWithoutBorders/App/Core/Event.cs index c30c59e547..1e6ee3e371 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Event.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Event.cs @@ -78,7 +78,7 @@ internal static class Event // if they are, check that there is no application running in fullscreen mode before switching. if (!p.IsEmpty && Common.IsEasyMouseSwitchAllowed()) { - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; Logger.LogDebug(string.Format( CultureInfo.CurrentCulture, @@ -218,10 +218,10 @@ internal static class Event if (MachineStuff.desMachineID == Common.MachineID) { - if (Common.GetTick() - Common.clipboardCopiedTime < Common.BIG_CLIPBOARD_DATA_TIMEOUT) + if (Common.GetTick() - Clipboard.clipboardCopiedTime < Clipboard.BIG_CLIPBOARD_DATA_TIMEOUT) { - Common.clipboardCopiedTime = 0; - Common.GetRemoteClipboard("PrepareToSwitchToMachine"); + Clipboard.clipboardCopiedTime = 0; + Clipboard.GetRemoteClipboard("PrepareToSwitchToMachine"); } } else diff --git a/src/modules/MouseWithoutBorders/App/Core/Helper.cs b/src/modules/MouseWithoutBorders/App/Core/Helper.cs index 4122fbe31b..bd66c9a83f 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Helper.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Helper.cs @@ -119,7 +119,7 @@ internal static class Helper if (MachineStuff.NewDesMachineID == Common.MachineID) { - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); } } } @@ -317,7 +317,7 @@ internal static class Helper Common.GetInputDesktop(), 0); - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; // Common.CreateLowIntegrityProcess("\"" + Path.GetDirectoryName(Application.ExecutablePath) + "\\MouseWithoutBordersHelper.exe\"", string.Empty, 0, false, 0); var processes = Process.GetProcessesByName(HelperProcessName); diff --git a/src/modules/MouseWithoutBorders/App/Core/InitAndCleanup.cs b/src/modules/MouseWithoutBorders/App/Core/InitAndCleanup.cs new file mode 100644 index 0000000000..963775cbca --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/InitAndCleanup.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft 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.Net.NetworkInformation; +using System.Security.Cryptography; +using System.Threading; + +using Microsoft.Win32; +using MouseWithoutBorders.Class; +using Windows.UI.Input.Preview.Injection; + +// +// Initialization and clean up. +// +// +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// +namespace MouseWithoutBorders.Core; + +internal static class InitAndCleanup +{ + private static bool initDone; + internal static int REOPEN_WHEN_WSAECONNRESET = -10054; + internal static int REOPEN_WHEN_HOTKEY = -10055; + internal static int PleaseReopenSocket; + internal static bool ReopenSocketDueToReadError; + + private static DateTime LastResumeSuspendTime { get; set; } = DateTime.UtcNow; + + internal static bool InitDone + { + get => InitAndCleanup.initDone; + set => InitAndCleanup.initDone = value; + } + + internal static void UpdateMachineTimeAndID() + { + Common.MachineName = Common.MachineName.Trim(); + _ = MachineStuff.MachinePool.TryUpdateMachineID(Common.MachineName, Common.MachineID, true); + } + + private static void InitializeMachinePoolFromSettings() + { + try + { + MachineInf[] info = MachinePoolHelpers.LoadMachineInfoFromMachinePoolStringSetting(Setting.Values.MachinePoolString); + for (int i = 0; i < info.Length; i++) + { + info[i].Name = info[i].Name.Trim(); + } + + MachineStuff.MachinePool.Initialize(info); + MachineStuff.MachinePool.ResetIPAddressesForDeadMachines(true); + } + catch (Exception ex) + { + Logger.Log(ex); + MachineStuff.MachinePool.Clear(); + } + } + + private static void SetupMachineNameAndID() + { + try + { + Common.GetMachineName(); + Common.DesMachineID = MachineStuff.NewDesMachineID = Common.MachineID; + + // MessageBox.Show(machineID.ToString(CultureInfo.CurrentCulture)); // For test + InitializeMachinePoolFromSettings(); + + Common.MachineName = Common.MachineName.Trim(); + _ = MachineStuff.MachinePool.LearnMachine(Common.MachineName); + _ = MachineStuff.MachinePool.TryUpdateMachineID(Common.MachineName, Common.MachineID, true); + + MachineStuff.UpdateMachinePoolStringSetting(); + } + catch (Exception e) + { + Logger.Log(e); + } + } + + internal static void Init() + { + _ = Helper.GetUserName(); + Common.GeneratedKey = true; + + try + { + Common.MyKey = Setting.Values.MyKey; + int tmp = Setting.Values.MyKeyDaysToExpire; + } + catch (FormatException e) + { + Common.KeyCorrupted = true; + Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); + Logger.Log(e.Message); + } + catch (CryptographicException e) + { + Common.KeyCorrupted = true; + Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); + Logger.Log(e.Message); + } + + try + { + InputSimulation.Injector = InputInjector.TryCreate(); + if (InputSimulation.Injector != null) + { + InputSimulation.MoveMouseRelative(0, 0); + NativeMethods.InjectMouseInputAvailable = true; + } + } + catch (EntryPointNotFoundException) + { + NativeMethods.InjectMouseInputAvailable = false; + Logger.Log($"{nameof(NativeMethods.InjectMouseInputAvailable)} = false"); + } + + bool dummy = Setting.Values.DrawMouseEx; + Common.Is64bitOS = IntPtr.Size == 8; + Common.tcpPort = Setting.Values.TcpPort; + Common.GetScreenConfig(); + Common.PackageSent = new PackageMonitor(0); + Common.PackageReceived = new PackageMonitor(0); + SetupMachineNameAndID(); + Common.InitEncryption(); + CreateHelperThreads(); + + SystemEvents.DisplaySettingsChanged += new EventHandler(Common.SystemEvents_DisplaySettingsChanged); + NetworkChange.NetworkAvailabilityChanged += new NetworkAvailabilityChangedEventHandler(NetworkChange_NetworkAvailabilityChanged); + SystemEvents.PowerModeChanged += new PowerModeChangedEventHandler(SystemEvents_PowerModeChanged); + PleaseReopenSocket = 9; + /* TODO: Telemetry for the matrix? */ + } + + private static void SystemEvents_PowerModeChanged(object sender, PowerModeChangedEventArgs e) + { + Helper.WndProcCounter++; + + if (e.Mode is PowerModes.Resume or PowerModes.Suspend) + { + Logger.TelemetryLogTrace($"{nameof(SystemEvents_PowerModeChanged)}: {e.Mode}", SeverityLevel.Information); + LastResumeSuspendTime = DateTime.UtcNow; + MachineStuff.SwitchToMultipleMode(false, true); + } + } + + private static void CreateHelperThreads() + { + // NOTE(@yuyoyuppe): service crashes while trying to obtain this info, disabling. + /* + Thread watchDogThread = new(new ThreadStart(WatchDogThread), nameof(WatchDogThread)); + watchDogThread.Priority = ThreadPriority.Highest; + watchDogThread.Start(); + */ + + Common.helper = new Thread(new ThreadStart(Helper.HelperThread), "Helper Thread"); + Common.helper.SetApartmentState(ApartmentState.STA); + Common.helper.Start(); + } + + private static void AskHelperThreadsToExit(int waitTime) + { + Helper.signalHelperToExit = true; + Helper.signalWatchDogToExit = true; + _ = Common.EvSwitch.Set(); + + int c = 0; + if (Common.helper != null && c < waitTime) + { + while (Helper.signalHelperToExit) + { + Thread.Sleep(1); + } + + Common.helper = null; + } + } + + internal static void Cleanup() + { + try + { + Common.SendByeBye(); + + // UnhookClipboard(); + AskHelperThreadsToExit(500); + Common.MainForm.NotifyIcon.Visible = false; + Common.MainForm.NotifyIcon.Dispose(); + Common.CloseAllFormsAndHooks(); + + Common.DoSomethingInUIThread(() => + { + Common.Sk?.Close(true); + }); + } + catch (Exception e) + { + Logger.Log(e); + } + } + + private static long lastReleaseAllKeysCall; + + internal static void ReleaseAllKeys() + { + if (Math.Abs(Common.GetTick() - lastReleaseAllKeysCall) < 2000) + { + return; + } + + lastReleaseAllKeysCall = Common.GetTick(); + + KEYBDDATA kd; + kd.dwFlags = (int)Common.LLKHF.UP; + + VK[] keys = new VK[] + { + VK.LSHIFT, VK.LCONTROL, VK.LMENU, VK.LWIN, VK.RSHIFT, + VK.RCONTROL, VK.RMENU, VK.RWIN, VK.SHIFT, VK.MENU, VK.CONTROL, + }; + + Logger.LogDebug("***** ReleaseAllKeys has been called! *****:"); + + foreach (VK vk in keys) + { + if ((NativeMethods.GetAsyncKeyState((IntPtr)vk) & 0x8000) != 0) + { + Logger.LogDebug(vk.ToString() + " is down, release it..."); + Common.Hook?.ResetLastSwitchKeys(); // Sticky key can turn ALL PC mode on (CtrlCtrlCtrl) + kd.wVk = (int)vk; + InputSimulation.SendKey(kd); + Common.Hook?.ResetLastSwitchKeys(); + } + } + } + + private static void NetworkChange_NetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e) + { + Logger.LogDebug("NetworkAvailabilityEventArgs.IsAvailable: " + e.IsAvailable.ToString(CultureInfo.InvariantCulture)); + Helper.WndProcCounter++; + ScheduleReopenSocketsDueToNetworkChanges(!e.IsAvailable); + } + + private static void ScheduleReopenSocketsDueToNetworkChanges(bool closeSockets = true) + { + if (closeSockets) + { + // Slept/hibernated machine may still have the sockets' status as Connected:( (unchanged) so it would not re-connect after a timeout when waking up. + // Closing the sockets when it is going to sleep/hibernate will trigger the reconnection faster when it wakes up. + Common.DoSomethingInUIThread( + () => + { + SocketStuff s = Common.Sk; + Common.Sk = null; + s?.Close(false); + }, + true); + } + + if (!Common.IsMyDesktopActive()) + { + PleaseReopenSocket = 0; + } + else if (PleaseReopenSocket != 10) + { + PleaseReopenSocket = 10; + } + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/Logger.cs b/src/modules/MouseWithoutBorders/App/Core/Logger.cs index 6635de59f0..86ce7605b5 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Logger.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Logger.cs @@ -247,22 +247,24 @@ internal static class Logger internal static void DumpStaticTypes(StringBuilder sb, int level) { - sb.AppendLine($"[{nameof(DragDrop)}]\r\n==============="); - Logger.DumpType(sb, typeof(DragDrop), 0, level); - sb.AppendLine($"[{nameof(Event)}]\r\n==============="); - Logger.DumpType(sb, typeof(Event), 0, level); - sb.AppendLine($"[{nameof(Helper)}]\r\n==============="); - Logger.DumpType(sb, typeof(Helper), 0, level); - sb.AppendLine($"[{nameof(Launch)}]\r\n==============="); - Logger.DumpType(sb, typeof(Launch), 0, level); - sb.AppendLine($"[{nameof(Logger)}]\r\n==============="); - Logger.DumpType(sb, typeof(Logger), 0, level); - sb.AppendLine($"[{nameof(MachineStuff)}]\r\n==============="); - Logger.DumpType(sb, typeof(MachineStuff), 0, level); - sb.AppendLine($"[{nameof(Receiver)}]\r\n==============="); - Logger.DumpType(sb, typeof(Receiver), 0, level); - sb.AppendLine($"[{nameof(Service)}]\r\n==============="); - Logger.DumpType(sb, typeof(Service), 0, level); + var staticTypes = new List + { + typeof(Clipboard), + typeof(DragDrop), + typeof(Event), + typeof(InitAndCleanup), + typeof(Helper), + typeof(Launch), + typeof(Logger), + typeof(MachineStuff), + typeof(Receiver), + typeof(Service), + }; + foreach (var staticType in staticTypes) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[{staticType.Name}]\r\n==============="); + Logger.DumpType(sb, staticType, 0, level); + } } internal static bool PrivateDump(StringBuilder sb, object obj, string objName, int level, int maxLevel, bool stop) diff --git a/src/modules/MouseWithoutBorders/App/Core/MachineStuff.cs b/src/modules/MouseWithoutBorders/App/Core/MachineStuff.cs index 144102629f..e5263aa788 100644 --- a/src/modules/MouseWithoutBorders/App/Core/MachineStuff.cs +++ b/src/modules/MouseWithoutBorders/App/Core/MachineStuff.cs @@ -992,7 +992,7 @@ internal static class MachineStuff Setting.Values.MatrixOneRow = !((package.Type & PackageType.MatrixTwoRowFlag) == PackageType.MatrixTwoRowFlag); MachineMatrix = MachineMatrix; // Save - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; UpdateClientSockets("UpdateMachineMatrix"); @@ -1044,7 +1044,7 @@ internal static class MachineStuff Common.MoveMouseToCenter(); } - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); Common.UpdateMultipleModeIconAndMenu(); } diff --git a/src/modules/MouseWithoutBorders/App/Core/Receiver.cs b/src/modules/MouseWithoutBorders/App/Core/Receiver.cs index 303bec4ff0..1b1e0730b0 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Receiver.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Receiver.cs @@ -157,7 +157,7 @@ internal static class Receiver if (!p.IsEmpty) { - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; Logger.LogDebug(string.Format( CultureInfo.CurrentCulture, @@ -274,7 +274,7 @@ internal static class Receiver Common.PackageReceived.Clipboard++; if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) { - Common.clipboardCopiedTime = Common.GetTick(); + Clipboard.clipboardCopiedTime = Common.GetTick(); GetNameOfMachineWithClipboardData(package); SignalBigClipboardData(); } @@ -282,10 +282,10 @@ internal static class Receiver break; case PackageType.MachineSwitched: - if (Common.GetTick() - Common.clipboardCopiedTime < Common.BIG_CLIPBOARD_DATA_TIMEOUT && (package.Des == Common.MachineID)) + if (Common.GetTick() - Clipboard.clipboardCopiedTime < Clipboard.BIG_CLIPBOARD_DATA_TIMEOUT && (package.Des == Common.MachineID)) { - Common.clipboardCopiedTime = 0; - Common.GetRemoteClipboard("PackageType.MachineSwitched"); + Clipboard.clipboardCopiedTime = 0; + Clipboard.GetRemoteClipboard("PackageType.MachineSwitched"); } break; @@ -297,7 +297,7 @@ internal static class Receiver if (package.Des == Common.MachineID || package.Des == ID.ALL) { GetNameOfMachineWithClipboardData(package); - Common.GetRemoteClipboard("mspaint," + Common.LastMachineWithClipboardData); + Clipboard.GetRemoteClipboard("mspaint," + Clipboard.LastMachineWithClipboardData); } } @@ -326,10 +326,10 @@ internal static class Receiver Thread.UpdateThreads(thread); string remoteMachine = package.MachineName; - System.Net.Sockets.TcpClient client = Common.ConnectToRemoteClipboardSocket(remoteMachine); + System.Net.Sockets.TcpClient client = Clipboard.ConnectToRemoteClipboardSocket(remoteMachine); bool clientPushData = true; - if (Common.ShakeHand(ref remoteMachine, client.Client, out Stream enStream, out Stream deStream, ref clientPushData, ref package.PostAction)) + if (Clipboard.ShakeHand(ref remoteMachine, client.Client, out Stream enStream, out Stream deStream, ref clientPushData, ref package.PostAction)) { SocketStuff.SendClipboardData(client.Client, enStream); } @@ -360,7 +360,7 @@ internal static class Receiver case PackageType.ClipboardText: case PackageType.ClipboardImage: - Common.clipboardCopiedTime = 0; + Clipboard.clipboardCopiedTime = 0; if (package.Type == PackageType.ClipboardImage) { Common.PackageReceived.ClipboardImage++; @@ -372,7 +372,7 @@ internal static class Receiver if (tcp != null) { - Common.ReceiveClipboardDataUsingTCP( + Clipboard.ReceiveClipboardDataUsingTCP( package, package.Type == PackageType.ClipboardImage, tcp); @@ -381,10 +381,10 @@ internal static class Receiver break; case PackageType.HideMouse: - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; Common.HideMouseCursor(true); Helper.MainFormDotEx(false); - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); break; default: @@ -405,11 +405,11 @@ internal static class Receiver internal static void GetNameOfMachineWithClipboardData(DATA package) { - Common.LastIDWithClipboardData = package.Src; - List matchingMachines = MachineStuff.MachinePool.TryFindMachineByID(Common.LastIDWithClipboardData); + Clipboard.LastIDWithClipboardData = package.Src; + List matchingMachines = MachineStuff.MachinePool.TryFindMachineByID(Clipboard.LastIDWithClipboardData); if (matchingMachines.Count >= 1) { - Common.LastMachineWithClipboardData = matchingMachines[0].Name.Trim(); + Clipboard.LastMachineWithClipboardData = matchingMachines[0].Name.Trim(); } /* diff --git a/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage3a.cs b/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage3a.cs index 84d464d33d..97884d4821 100644 --- a/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage3a.cs +++ b/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage3a.cs @@ -84,7 +84,7 @@ namespace MouseWithoutBorders if ((connectedClientSocket = Common.GetConnectedClientSocket()) != null) { ShowStatus($"Connected from local IP Address: {connectedClientSocket.Address}."); - Common.UpdateMachineTimeAndID(); + InitAndCleanup.UpdateMachineTimeAndID(); Common.MMSleep(1); connected = true; diff --git a/src/modules/MouseWithoutBorders/App/Form/frmMatrix.cs b/src/modules/MouseWithoutBorders/App/Form/frmMatrix.cs index ca095d8140..66301c52cb 100644 --- a/src/modules/MouseWithoutBorders/App/Form/frmMatrix.cs +++ b/src/modules/MouseWithoutBorders/App/Form/frmMatrix.cs @@ -22,6 +22,8 @@ using Microsoft.PowerToys.Telemetry; // using MouseWithoutBorders.Class; using MouseWithoutBorders.Core; + +using Clipboard = MouseWithoutBorders.Core.Clipboard; using Timer = System.Windows.Forms.Timer; [module: SuppressMessage("Microsoft.Globalization", "CA1300:SpecifyMessageBoxOptions", Scope = "member", Target = "MouseWithoutBorders.frmMatrix.#buttonOK_Click(System.Object,System.EventArgs)", Justification = "Dotnet port with style preservation")] @@ -110,7 +112,7 @@ namespace MouseWithoutBorders { SocketStuff.InvalidKeyFound = false; showInvalidKeyMessage = false; - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; Common.ReopenSockets(true); for (int i = 0; i < 10; i++) @@ -780,7 +782,7 @@ namespace MouseWithoutBorders ShowUpdateMessage(); - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; } private void CheckBoxDisableCAD_CheckedChanged(object sender, EventArgs e) diff --git a/src/modules/MouseWithoutBorders/App/Form/frmScreen.cs b/src/modules/MouseWithoutBorders/App/Form/frmScreen.cs index b01a653f60..1ab0ce8cc7 100644 --- a/src/modules/MouseWithoutBorders/App/Form/frmScreen.cs +++ b/src/modules/MouseWithoutBorders/App/Form/frmScreen.cs @@ -139,13 +139,13 @@ namespace MouseWithoutBorders { if (cleanup) { - Common.Cleanup(); + InitAndCleanup.Cleanup(); } Helper.WndProcCounter++; if (!Common.RunOnScrSaverDesktop) { - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); } Helper.RunDDHelper(true); @@ -412,7 +412,7 @@ namespace MouseWithoutBorders count = 0; - Common.InitDone = true; + InitAndCleanup.InitDone = true; #if SHOW_ON_WINLOGON if (Common.RunOnLogonDesktop) { @@ -423,39 +423,39 @@ namespace MouseWithoutBorders if ((count % 2) == 0) { - if (Common.PleaseReopenSocket == 10 || (Common.PleaseReopenSocket > 0 && count > 0 && count % 300 == 0)) + if (InitAndCleanup.PleaseReopenSocket == 10 || (InitAndCleanup.PleaseReopenSocket > 0 && count > 0 && count % 300 == 0)) { - if (!Common.AtLeastOneSocketEstablished() || Common.PleaseReopenSocket == 10) + if (!Common.AtLeastOneSocketEstablished() || InitAndCleanup.PleaseReopenSocket == 10) { Thread.Sleep(1000); - if (Common.PleaseReopenSocket > 0) + if (InitAndCleanup.PleaseReopenSocket > 0) { - Common.PleaseReopenSocket--; + InitAndCleanup.PleaseReopenSocket--; } // Double check. if (!Common.AtLeastOneSocketEstablished()) { Common.GetMachineName(); - Logger.LogDebug("Common.pleaseReopenSocket: " + Common.PleaseReopenSocket.ToString(CultureInfo.InvariantCulture)); + Logger.LogDebug("Common.pleaseReopenSocket: " + InitAndCleanup.PleaseReopenSocket.ToString(CultureInfo.InvariantCulture)); Common.ReopenSockets(false); MachineStuff.NewDesMachineID = Common.DesMachineID = Common.MachineID; } } else { - Common.PleaseReopenSocket = 0; + InitAndCleanup.PleaseReopenSocket = 0; } } - if (Common.PleaseReopenSocket == Common.REOPEN_WHEN_HOTKEY) + if (InitAndCleanup.PleaseReopenSocket == InitAndCleanup.REOPEN_WHEN_HOTKEY) { - Common.PleaseReopenSocket = 0; + InitAndCleanup.PleaseReopenSocket = 0; Common.ReopenSockets(true); } - else if (Common.PleaseReopenSocket == Common.REOPEN_WHEN_WSAECONNRESET) + else if (InitAndCleanup.PleaseReopenSocket == InitAndCleanup.REOPEN_WHEN_WSAECONNRESET) { - Common.PleaseReopenSocket = 0; + InitAndCleanup.PleaseReopenSocket = 0; Thread.Sleep(1000); MachineStuff.UpdateClientSockets("REOPEN_WHEN_WSAECONNRESET"); } diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/Logger.PrivateDump.expected.txt b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/Logger.PrivateDump.expected.txt index 3c933bfff1..1bbd8ba49c 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/Logger.PrivateDump.expected.txt +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/Logger.PrivateDump.expected.txt @@ -4,28 +4,6 @@ [Other Logs] =============== = MouseWithoutBorders.Common -Comma = System.Char[] ---System.Char[] = System.Char[]: N/A -Star = System.Char[] ---System.Char[] = System.Char[]: N/A -NullSeparator = System.Char[] ---System.Char[] = System.Char[]: N/A -lastClipboardEventTime = 0 -clipboardCopiedTime = 0 -k__BackingField = NONE -k__BackingField = 0 -k__BackingField = False -lastClipboardObject = -k__BackingField = False -ClipboardThreadOldLock = Lock ---_owningThreadId = 0 ---_state = 0 ---_recursionCount = 0 ---_spinCount = 22 ---_waiterStartTimeMs = 0 ---s_contentionCount = 0 ---s_maxSpinCount = 22 ---s_minSpinCountForAdaptiveSpin = -100 screenWidth = 0 screenHeight = 0 lastX = 0 @@ -99,17 +77,6 @@ LegalKeyDictionary = Concurrent.ConcurrentDictionary`2[System.String,System.Byte --_budget = ???????????? --_growLockArray = True --_comparerIsDefaultForClasses = False -initDone = False -REOPEN_WHEN_WSAECONNRESET = -10054 -REOPEN_WHEN_HOTKEY = -10055 -PleaseReopenSocket = 0 -ReopenSocketDueToReadError = False -k__BackingField = ???????????? ---_dateData = ???????????? ---MinValue = 01/01/0001 00:00:00 ---MaxValue = 31/12/9999 23:59:59 ---UnixEpoch = 01/01/1970 00:00:00 -lastReleaseAllKeysCall = 0 PackageSent = MouseWithoutBorders.PackageMonitor --Keyboard = 0 --Mouse = 0 @@ -153,12 +120,6 @@ p = {X=0,Y=0} --y = 0 --Empty = {X=0,Y=0} k__BackingField = False -BIG_CLIPBOARD_DATA_TIMEOUT = 30000 -MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP = 1048576 -MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT = 104857600 -TEXT_HEADER_SIZE = 12 -DATA_SIZE = 48 -TEXT_TYPE_SEP = {4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F} TOGGLE_ICONS_SIZE = 4 ICON_ONE = 0 ICON_ALL = 1 @@ -195,6 +156,36 @@ WM_KEYDOWN = 256 WM_KEYUP = 257 WM_SYSKEYDOWN = 260 WM_SYSKEYUP = 261 +[Clipboard] +=============== +Comma = System.Char[] +--System.Char[] = System.Char[]: N/A +Star = System.Char[] +--System.Char[] = System.Char[]: N/A +NullSeparator = System.Char[] +--System.Char[] = System.Char[]: N/A +lastClipboardEventTime = 0 +clipboardCopiedTime = 0 +k__BackingField = NONE +k__BackingField = 0 +k__BackingField = False +lastClipboardObject = +k__BackingField = False +ClipboardThreadOldLock = Lock +--_owningThreadId = 0 +--_state = 0 +--_recursionCount = 0 +--_spinCount = 22 +--_waiterStartTimeMs = 0 +--s_contentionCount = 0 +--s_maxSpinCount = 22 +--s_minSpinCountForAdaptiveSpin = -100 +BIG_CLIPBOARD_DATA_TIMEOUT = 30000 +MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP = 1048576 +MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT = 104857600 +TEXT_HEADER_SIZE = 12 +DATA_SIZE = 48 +TEXT_TYPE_SEP = {4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F} [DragDrop] =============== isDragging = False @@ -249,6 +240,19 @@ actualLastPos = {X=0,Y=0} --Empty = {X=0,Y=0} myLastX = 0 myLastY = 0 +[InitAndCleanup] +=============== +initDone = False +REOPEN_WHEN_WSAECONNRESET = -10054 +REOPEN_WHEN_HOTKEY = -10055 +PleaseReopenSocket = 0 +ReopenSocketDueToReadError = False +k__BackingField = ???????????? +--_dateData = ???????????? +--MinValue = 01/01/0001 00:00:00 +--MaxValue = 31/12/9999 23:59:59 +--UnixEpoch = 01/01/1970 00:00:00 +lastReleaseAllKeysCall = 0 [Helper] =============== signalHelperToExit = False diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.h b/src/modules/ZoomIt/ZoomIt/ZoomIt.h index 5abbc21039..2687ba2b65 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.h +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.h @@ -96,7 +96,10 @@ typedef struct { #define SHALLOW_DESTROY 2 #define LIVE_DRAW_ZOOM 3 -#define PEN_COLOR_HIGHLIGHT(Pencolor) (Pencolor >> 24) != 0xFF +#define PEN_COLOR_HIGHLIGHT(Pencolor) ((Pencolor >> 24) != 0xFF) +#define PEN_COLOR_BLUR(Pencolor) ((Pencolor & 0x00FFFFFF) == COLOR_BLUR) + +#define CURSOR_SAVE_MARGIN 4 typedef BOOL (__stdcall *type_pGetMonitorInfo)( @@ -143,7 +146,14 @@ typedef BOOL(__stdcall *type_pMagSetWindowFilterList)( int count, HWND* pHWND ); -typedef BOOL (__stdcall *type_pMagInitialize)(VOID); +typedef BOOL(__stdcall* type_pMagSetLensUseBitmapSmoothing)( + _In_ HWND, + _In_ BOOL +); +typedef BOOL(__stdcall* type_MagSetFullscreenUseBitmapSmoothing)( + BOOL fUseBitmapSmoothing +); +typedef BOOL(__stdcall* type_pMagInitialize)(VOID); typedef BOOL(__stdcall *type_pGetPointerType)( _In_ UINT32 pointerId, diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc index 7862538c4a..8b8cab11eb 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc @@ -2,13 +2,6 @@ // #include "resource.h" -// version.h and branding.h are different in the Sysinternals repository, -// keep the includes as such, here. -// From $(MSBuildThisFileDirectory)..\..\..\common\version -#include "version.h" -// From $(MSBuildThisFileDirectory)PowerToys -#include "branding.h" - #define APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // @@ -75,8 +68,8 @@ APPICON ICON "appicon.ico" // VS_VERSION_INFO VERSIONINFO - FILEVERSION FILE_VERSION - PRODUCTVERSION PRODUCT_VERSION + FILEVERSION 9,10,0,0 + PRODUCTVERSION 9,10,0,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -91,14 +84,14 @@ BEGIN BEGIN BLOCK "040904b0" 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", ZOOMIT_PRODUCT_NAME - VALUE "ProductVersion", PRODUCT_VERSION_STRING + VALUE "CompanyName", "Microsoft Corporation" + VALUE "FileDescription", "Sysinternals Screen Magnifier" + VALUE "FileVersion", "9.10" + VALUE "InternalName", "ZoomIt" + VALUE "LegalCopyright", "Copyright (C) Microsoft Corporation. All rights reserved." + VALUE "OriginalFilename", "PowerToys.ZoomIt.exe" + VALUE "ProductName", "PowerToys Sysinternals ZoomIt" + VALUE "ProductVersion", "9.10" END END BLOCK "VarFileInfo" @@ -122,7 +115,7 @@ BEGIN DEFPUSHBUTTON "OK",IDOK,166,306,50,14 PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14 LTEXT "ZoomIt v9.01",IDC_VERSION,42,7,73,10 - LTEXT "Copyright 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,166,8 + LTEXT "Copyright � 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8 CONTROL "Sysinternals - www.sysinternals.com",IDC_LINK, "SysLink",WS_TABSTOP,42,26,150,9 ICON "APPICON",IDC_STATIC,12,9,20,20 @@ -149,7 +142,8 @@ BEGIN CONTROL "",IDC_TIMER_POS7,"Button",BS_AUTORADIOBUTTON,63,108,10,10 CONTROL "",IDC_TIMER_POS8,"Button",BS_AUTORADIOBUTTON,79,108,10,10 CONTROL "",IDC_TIMER_POS9,"Button",BS_AUTORADIOBUTTON,97,108,10,10 - CONTROL "Show background bitmap:",IDC_CHECK_BACKGROUND_FILE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,3,122,99,10,WS_EX_RIGHT + CONTROL "Show background bitmap:",IDC_CHECK_BACKGROUND_FILE, + "Button",BS_AUTOCHECKBOX | WS_TABSTOP,3,122,99,10,WS_EX_RIGHT CONTROL "Use faded desktop as background",IDC_STATIC_DESKTOP_BACKGROUND, "Button",BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP,46,135,125,10 CONTROL "Use image file as background",IDC_STATIC_BACKGROUND_FILE, @@ -165,23 +159,25 @@ BEGIN CONTROL "",IDC_STATIC,"Static",SS_BLACKFRAME | SS_SUNKEN,7,196,193,1,WS_EX_CLIENTEDGE END -ZOOM DIALOGEX 0, 0, 260, 158 +ZOOM DIALOGEX 0, 0, 260, 170 STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12 LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,246,26 LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8 - CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,104,150,15,WS_EX_TRANSPARENT - LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,91,215,10 - LTEXT "1.25",IDC_STATIC,52,122,16,8 - LTEXT "1.5",IDC_STATIC,82,122,12,8 - LTEXT "1.75",IDC_STATIC,108,122,16,8 - LTEXT "2.0",IDC_STATIC,138,122,12,8 - LTEXT "3.0",IDC_STATIC,164,122,12,8 - LTEXT "4.0",IDC_STATIC,190,122,12,8 + CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT + LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,215,10 + LTEXT "1.25",IDC_STATIC,52,136,16,8 + LTEXT "1.5",IDC_STATIC,82,136,12,8 + LTEXT "1.75",IDC_STATIC,108,136,16,8 + LTEXT "2.0",IDC_STATIC,138,136,12,8 + LTEXT "3.0",IDC_STATIC,164,136,12,8 + LTEXT "4.0",IDC_STATIC,190,136,12,8 CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10 - LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,34,246,17 + CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10 + LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,246,17 + LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,246,18 END DRAW DIALOGEX 0, 0, 260, 228 @@ -295,7 +291,8 @@ BEGIN LTEXT "DemoType toggle:",IDC_STATIC,7,157,63,8 PUSHBUTTON "&...",IDC_DEMOTYPE_BROWSE,231,137,16,13 CONTROL "",IDC_DEMOTYPE_SPEED_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,52,202,150,11,WS_EX_TRANSPARENT - CONTROL "Drive input with typing:",IDC_DEMOTYPE_USER_DRIVEN,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,173,88,10 + CONTROL "Drive input with typing:",IDC_DEMOTYPE_USER_DRIVEN, + "Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,173,88,10 LTEXT "DemoType typing speed:",IDC_STATIC,7,189,215,10 LTEXT "Slow",IDC_DEMOTYPE_STATIC1,51,213,18,8 LTEXT "Fast",IDC_DEMOTYPE_STATIC2,186,213,17,8 @@ -413,8 +410,8 @@ ACCELERATORS ACCELERATORS BEGIN "C", IDC_COPY, VIRTKEY, CONTROL, NOINVERT "S", IDC_SAVE, VIRTKEY, CONTROL, NOINVERT - "C", IDC_COPY_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT - "S", IDC_SAVE_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT + "C", IDC_COPY_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT + "S", IDC_SAVE_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT END diff --git a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h index 952314796f..96e2f19e5b 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h +++ b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h @@ -14,6 +14,7 @@ DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6'; DWORD g_ShowExpiredTime = 1; DWORD g_SliderZoomLevel = 3; BOOLEAN g_AnimateZoom = TRUE; +BOOLEAN g_SmoothImage = TRUE; DWORD g_PenColor = COLOR_RED; DWORD g_BreakPenColor = COLOR_RED; DWORD g_RootPenWidth = PEN_WIDTH; @@ -72,6 +73,7 @@ REG_SETTING RegSettings[] = { { L"ShowTrayIcon", SETTING_TYPE_BOOLEAN, 0, &g_ShowTrayIcon, static_cast(g_ShowTrayIcon) }, // NOTE: AnimateZoom is misspelled, but since it is a user setting stored in the registry we must continue to misspell it. { L"AnimnateZoom", SETTING_TYPE_BOOLEAN, 0, &g_AnimateZoom, static_cast(g_AnimateZoom) }, + { L"SmoothImage", SETTING_TYPE_BOOLEAN, 0, &g_SmoothImage, static_cast(g_SmoothImage) }, { L"TelescopeZoomOut", SETTING_TYPE_BOOLEAN, 0, &g_TelescopeZoomOut, static_cast(g_TelescopeZoomOut) }, { L"SnapToGrid", SETTING_TYPE_BOOLEAN, 0, &g_SnapToGrid, static_cast(g_SnapToGrid) }, { L"ZoominSliderLevel", SETTING_TYPE_DWORD, 0, &g_SliderZoomLevel, static_cast(g_SliderZoomLevel) }, diff --git a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp index b5660ec7ff..fadc760339 100644 --- a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp +++ b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp @@ -170,6 +170,8 @@ type_pMagSetFullscreenTransform pMagSetFullscreenTransform; type_pMagSetInputTransform pMagSetInputTransform; type_pMagShowSystemCursor pMagShowSystemCursor; type_pMagSetWindowFilterList pMagSetWindowFilterList; +type_MagSetFullscreenUseBitmapSmoothing pMagSetFullscreenUseBitmapSmoothing; +type_pMagSetLensUseBitmapSmoothing pMagSetLensUseBitmapSmoothing; type_pMagInitialize pMagInitialize; type_pDwmIsCompositionEnabled pDwmIsCompositionEnabled; type_pGetPointerType pGetPointerType; @@ -1099,6 +1101,8 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr // Create a new bitmap that's the size of the area covered by the line + 2 * g_PenWidth Gdiplus::Rect lineBounds(min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1)); + OutputDebug(L"DrawHighlightedShape\n"); + // Expand for line drawing if (Shape == DRAW_LINE) lineBounds.Inflate(static_cast(g_PenWidth / 2), static_cast(g_PenWidth / 2)); @@ -1186,7 +1190,7 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr DeleteDC(hdcDIBOrig); // Invalidate the updated rectangle - // InvalidateGdiplusRect(hWnd, lineBounds); + //InvalidateGdiplusRect(hWnd, lineBounds); } //---------------------------------------------------------------------------- @@ -1284,7 +1288,12 @@ void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, { Gdiplus::Bitmap srcBitmap( bmSrc, NULL ); - dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeLowQuality ); + // Use high quality interpolation when smooth image is enabled + if (g_SmoothImage) { + dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeHighQuality ); + } else { + dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeLowQuality ); + } dstGraphics.SetPixelOffsetMode( Gdiplus::PixelOffsetModeHalf ); dstGraphics.DrawImage( &srcBitmap, Gdiplus::RectF(xDst,yDst,wDst,hDst), xSrc, ySrc, wSrc, hSrc, Gdiplus::UnitPixel ); @@ -2071,6 +2080,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, IsAutostartConfigured() ? BST_CHECKED: BST_UNCHECKED ); CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM, g_AnimateZoom ? BST_CHECKED: BST_UNCHECKED ); + CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE, + g_SmoothImage ? BST_CHECKED: BST_UNCHECKED ); SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_SETRANGE, false, MAKELONG(0,_countof(g_ZoomLevels)-1) ); SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_SETPOS, true, g_SliderZoomLevel ); @@ -2210,6 +2221,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, } g_ShowTrayIcon = IsDlgButtonChecked( hDlg, IDC_SHOW_TRAY_ICON ) == BST_CHECKED; g_AnimateZoom = IsDlgButtonChecked( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM ) == BST_CHECKED; + g_SmoothImage = IsDlgButtonChecked( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE ) == BST_CHECKED; g_DemoTypeUserDriven = IsDlgButtonChecked( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_USER_DRIVEN ) == BST_CHECKED; newToggleKey = static_cast(SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_GETHOTKEY, 0, 0 )); @@ -2723,7 +2735,6 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false ) bool isBlur = false; Gdiplus::Graphics dstGraphics(hDc); - if( ( GetWindowLong( g_hWndMain, GWL_EXSTYLE ) & WS_EX_LAYERED ) == 0 ) { dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); @@ -2746,6 +2757,7 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false ) InflateRect(Rect, g_PenWidth / 2, g_PenWidth / 2); isBlur = true; } + OutputDebug(L"Draw shape: highlight: %d pbrush: %d\n", PEN_COLOR_HIGHLIGHT(g_PenColor), pBrush != NULL); switch (Shape) { case DRAW_RECTANGLE: @@ -2920,7 +2932,7 @@ void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height { int x, y; RECT rc; - int invWidth = g_PenWidth; + int invWidth = g_PenWidth + CURSOR_SAVE_MARGIN; if( DrawHighlightedCursor( zoomLevel, width, height ) ) { @@ -2945,7 +2957,7 @@ void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height void SaveCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt ) { OutputDebug( L"SaveCursorArea\n"); - int penWidth = g_PenWidth + 2; + int penWidth = g_PenWidth + CURSOR_SAVE_MARGIN; BitBlt( hDcTarget, 0, 0, penWidth +CURSOR_ARM_LENGTH*2, penWidth +CURSOR_ARM_LENGTH*2, hDcSource, static_cast (pt.x- penWidth /2)-CURSOR_ARM_LENGTH, static_cast(pt.y- penWidth /2)-CURSOR_ARM_LENGTH, SRCCOPY|CAPTUREBLT ); @@ -2959,7 +2971,7 @@ void SaveCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt ) void RestoreCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt ) { OutputDebug( L"RestoreCursorArea\n"); - int penWidth = g_PenWidth + 2; + int penWidth = g_PenWidth + CURSOR_SAVE_MARGIN; BitBlt( hDcTarget, static_cast(pt.x- penWidth /2)-CURSOR_ARM_LENGTH, static_cast(pt.y- penWidth /2)-CURSOR_ARM_LENGTH, penWidth +CURSOR_ARM_LENGTH*2, penWidth + CURSOR_ARM_LENGTH*2, hDcSource, 0, 0, SRCCOPY|CAPTUREBLT ); @@ -4178,6 +4190,11 @@ LRESULT APIENTRY MainWndProc( } #endif } + OutputDebug(L"LIVEDRAW SMOOTHING: %d\n", g_SmoothImage); + if (!pMagSetLensUseBitmapSmoothing(g_hWndLiveZoomMag, g_SmoothImage)) + { + OutputDebug(L"MagSetLensUseBitmapSmoothing failed: %d\n", GetLastError()); + } if ( g_RecordToggle ) { @@ -5296,6 +5313,8 @@ LRESULT APIENTRY MainWndProc( if( g_Drawing ) { + OutputDebug(L"Mousemove: Drawing\n"); + POINT currentPt; // Are we in pen mode on a tablet? @@ -5334,7 +5353,15 @@ LRESULT APIENTRY MainWndProc( } else { - DrawShape( g_DrawingShape, hdcScreenCompat, &g_rcRectangle ); + if (PEN_COLOR_HIGHLIGHT(g_PenColor)) + { + // copy original bitmap to screen bitmap to erase previous highlight + BitBlt(hdcScreenCompat, 0, 0, bmp.bmWidth, bmp.bmHeight, drawUndoList->hDc, 0, 0, SRCCOPY | CAPTUREBLT); + } + else + { + DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle, PEN_COLOR_HIGHLIGHT(g_PenColor)); + } } } @@ -5380,7 +5407,7 @@ LRESULT APIENTRY MainWndProc( g_rcRectangle.top != g_rcRectangle.bottom) { // Draw the new target rectangle. - DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle); + DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle, PEN_COLOR_HIGHLIGHT(g_PenColor)); OutputDebug(L"SHAPE: (%d, %d) - (%d, %d)\n", g_rcRectangle.left, g_rcRectangle.top, g_rcRectangle.right, g_rcRectangle.bottom); } @@ -5418,9 +5445,6 @@ LRESULT APIENTRY MainWndProc( Gdiplus::BitmapData* lineData = LockGdiPlusBitmap(lineBitmap); BYTE* pPixels = static_cast(lineData->Scan0); - // Copy the contents of the screen bitmap to the temporary bitmap - GetOldestUndo(drawUndoList); - // Create a GDI bitmap that's the size of the lineBounds rectangle Gdiplus::Bitmap *blurBitmap = CreateGdiplusBitmap( hdcScreenCompat, // oldestUndo->hDc, lineBounds.X, lineBounds.Y, lineBounds.Width, lineBounds.Height); @@ -5445,6 +5469,8 @@ LRESULT APIENTRY MainWndProc( } else if(PEN_COLOR_HIGHLIGHT(g_PenColor)) { + OutputDebug(L"HIGHLIGHT\n"); + // This is a highlighting pen color Gdiplus::Rect lineBounds = GetLineBounds(prevPt, currentPt, g_PenWidth); Gdiplus::Bitmap* lineBitmap = DrawBitmapLine(lineBounds, prevPt, currentPt, &pen); @@ -5784,26 +5810,30 @@ LRESULT APIENTRY MainWndProc( if( !g_DrawingShape ) { // If the point has changed, draw a line to it - if (prevPt.x != LOWORD(lParam) || prevPt.y != HIWORD(lParam)) { - Gdiplus::Graphics dstGraphics(hdcScreenCompat); - if ((GetWindowLong(g_hWndMain, GWL_EXSTYLE) & WS_EX_LAYERED) == 0) + if (!PEN_COLOR_HIGHLIGHT(g_PenColor)) + { + if (prevPt.x != LOWORD(lParam) || prevPt.y != HIWORD(lParam)) { - dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + Gdiplus::Graphics dstGraphics(hdcScreenCompat); + if ((GetWindowLong(g_hWndMain, GWL_EXSTYLE) & WS_EX_LAYERED) == 0) + { + dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + } + Gdiplus::Color color = ColorFromColorRef(g_PenColor); + Gdiplus::Pen pen(color, static_cast(g_PenWidth)); + Gdiplus::GraphicsPath path; + pen.SetLineJoin(Gdiplus::LineJoinRound); + path.AddLine(prevPt.x, prevPt.y, LOWORD(lParam), HIWORD(lParam)); + dstGraphics.DrawPath(&pen, &path); + } + // Draw a dot at the current point, if the point hasn't changed + else + { + MoveToEx(hdcScreenCompat, prevPt.x, prevPt.y, NULL); + LineTo(hdcScreenCompat, LOWORD(lParam), HIWORD(lParam)); + InvalidateRect(hWnd, NULL, FALSE); } - Gdiplus::Color color = ColorFromColorRef(g_PenColor); - Gdiplus::Pen pen(color, static_cast(g_PenWidth)); - Gdiplus::GraphicsPath path; - pen.SetLineJoin(Gdiplus::LineJoinRound); - path.AddLine(prevPt.x, prevPt.y, LOWORD(lParam), HIWORD(lParam)); - dstGraphics.DrawPath(&pen, &path); } - // Draw a dot at the current point, if the point hasn't changed - else { - MoveToEx(hdcScreenCompat, prevPt.x, prevPt.y, NULL); - LineTo(hdcScreenCompat, LOWORD(lParam), HIWORD(lParam)); - InvalidateRect(hWnd, NULL, FALSE); - } - prevPt.x = LOWORD( lParam ); prevPt.y = HIWORD( lParam ); @@ -5818,8 +5848,11 @@ LRESULT APIENTRY MainWndProc( g_rcRectangle.left != g_rcRectangle.right ) { // erase previous - SetROP2(hdcScreenCompat, R2_NOTXORPEN); - DrawShape( g_DrawingShape, hdcScreenCompat, &g_rcRectangle ); + if (!PEN_COLOR_HIGHLIGHT(g_PenColor)) + { + SetROP2(hdcScreenCompat, R2_NOTXORPEN); + DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle); + } // Draw the final shape HBRUSH hBrush = static_cast(GetStockObject( NULL_BRUSH )); @@ -6185,8 +6218,14 @@ LRESULT APIENTRY MainWndProc( SetStretchBltMode( hInterimSaveDc, HALFTONE ); SetStretchBltMode( hSaveDc, HALFTONE ); #else - SetStretchBltMode( hInterimSaveDc, COLORONCOLOR ); - SetStretchBltMode( hSaveDc, COLORONCOLOR ); + // Use HALFTONE for better quality when smooth image is enabled + if (g_SmoothImage) { + SetStretchBltMode( hInterimSaveDc, HALFTONE ); + SetStretchBltMode( hSaveDc, HALFTONE ); + } else { + SetStretchBltMode( hInterimSaveDc, COLORONCOLOR ); + SetStretchBltMode( hSaveDc, COLORONCOLOR ); + } #endif StretchBlt( hInterimSaveDc, 0, 0, @@ -6309,7 +6348,12 @@ LRESULT APIENTRY MainWndProc( #if SCALE_HALFTONE SetStretchBltMode( hSaveDc, HALFTONE ); #else - SetStretchBltMode( hSaveDc, COLORONCOLOR ); + // Use HALFTONE for better quality when smooth image is enabled + if (g_SmoothImage) { + SetStretchBltMode( hSaveDc, HALFTONE ); + } else { + SetStretchBltMode( hSaveDc, COLORONCOLOR ); + } #endif StretchBlt( hSaveDc, 0, 0, @@ -6646,8 +6690,8 @@ LRESULT APIENTRY MainWndProc( (float)x, (float)y, width/zoomLevel, height/zoomLevel ); } else { - // do a fast, less accurate render - SetStretchBltMode( hDc, HALFTONE ); + // do a fast, less accurate render (but use smooth if enabled) + SetStretchBltMode( hDc, g_SmoothImage ? HALFTONE : COLORONCOLOR ); StretchBlt( ps.hdc, 0, 0, bmp.bmWidth, bmp.bmHeight, @@ -6660,7 +6704,12 @@ LRESULT APIENTRY MainWndProc( #if SCALE_HALFTONE SetStretchBltMode( hDc, zoomLevel == zoomTelescopeTarget ? HALFTONE : COLORONCOLOR ); #else - SetStretchBltMode( hDc, COLORONCOLOR ); + // Use HALFTONE for better quality when smooth image is enabled + if (g_SmoothImage) { + SetStretchBltMode( hDc, HALFTONE ); + } else { + SetStretchBltMode( hDc, COLORONCOLOR ); + } #endif StretchBlt( ps.hdc, 0, 0, @@ -6683,7 +6732,7 @@ LRESULT APIENTRY MainWndProc( BITMAP local_bmp; GetObject(g_hBackgroundBmp, sizeof(local_bmp), &local_bmp); - SetStretchBltMode( hdcScreenCompat, HALFTONE ); + SetStretchBltMode( hdcScreenCompat, g_SmoothImage ? HALFTONE : COLORONCOLOR ); if( g_BreakBackgroundStretch ) { StretchBlt( hdcScreenCompat, 0, 0, width, height, g_hDcBackgroundFile, 0, 0, local_bmp.bmWidth, local_bmp.bmHeight, SRCCOPY|CAPTUREBLT ); @@ -6842,7 +6891,6 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM WS_CHILD | MS_SHOWMAGNIFIEDCURSOR | WS_VISIBLE, 0, 0, 0, 0, hWnd, NULL, g_hInstance, NULL ); } - ShowWindow( hWnd, SW_SHOW ); InvalidateRect( g_hWndLiveZoomMag, NULL, TRUE ); @@ -7555,6 +7603,10 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance "MagSetWindowTransform" ); pMagSetFullscreenTransform = (type_pMagSetFullscreenTransform)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), "MagSetFullscreenTransform"); + pMagSetFullscreenUseBitmapSmoothing = (type_MagSetFullscreenUseBitmapSmoothing)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), + "MagSetFullscreenUseBitmapSmoothing"); + pMagSetLensUseBitmapSmoothing = (type_pMagSetLensUseBitmapSmoothing)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), + "MagSetLensUseBitmapSmoothing"); pMagSetInputTransform = (type_pMagSetInputTransform)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), "MagSetInputTransform"); pMagShowSystemCursor = (type_pMagShowSystemCursor)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), diff --git a/src/modules/ZoomIt/ZoomIt/resource.h b/src/modules/ZoomIt/ZoomIt/resource.h index c4c0cfad87..568eebda4b 100644 --- a/src/modules/ZoomIt/ZoomIt/resource.h +++ b/src/modules/ZoomIt/ZoomIt/resource.h @@ -95,6 +95,7 @@ #define IDC_COPYRIGHT 1075 #define IDC_PEN_WIDTH 1105 #define IDC_TIMER 1106 +#define IDC_SMOOTH_IMAGE 1107 #define IDC_SAVE 40002 #define IDC_COPY 40004 #define IDC_RECORD 40006 @@ -109,7 +110,7 @@ #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 118 #define _APS_NEXT_COMMAND_VALUE 40013 -#define _APS_NEXT_CONTROL_VALUE 1076 +#define _APS_NEXT_CONTROL_VALUE 1078 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp index 84cf9ed949..1ed96e79bd 100644 --- a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp @@ -258,16 +258,6 @@ private: { Logger::info("AlwaysOnTop settings are empty"); } - - if (!m_hotkey.key) - { - Logger::info("AlwaysOnTop is going to use default shortcut"); - m_hotkey.win = true; - m_hotkey.alt = false; - m_hotkey.shift = false; - m_hotkey.ctrl = true; - m_hotkey.key = 'T'; - } } bool is_process_running() diff --git a/src/modules/awake/Awake/Program.cs b/src/modules/awake/Awake/Program.cs index 23882b3018..4d6d20bc96 100644 --- a/src/modules/awake/Awake/Program.cs +++ b/src/modules/awake/Awake/Program.cs @@ -157,9 +157,33 @@ namespace Awake pidOption.AddValidator(result => { - if (result.Tokens.Count != 0 && !int.TryParse(result.Tokens[0].Value, out _)) + if (result.Tokens.Count == 0) { - string errorMessage = $"PID value in --pid could not be parsed correctly. Check that the value is valid and falls within the boundaries of Windows PID process limits. Value used: {result.Tokens[0].Value}."; + return; + } + + string tokenValue = result.Tokens[0].Value; + + if (!int.TryParse(tokenValue, out int parsed)) + { + string errorMessage = $"PID value in --pid could not be parsed correctly. Check that the value is valid and falls within the boundaries of Windows PID process limits. Value used: {tokenValue}."; + Logger.LogError(errorMessage); + result.ErrorMessage = errorMessage; + return; + } + + if (parsed <= 0) + { + string errorMessage = $"PID value in --pid must be a positive integer. Value used: {parsed}."; + Logger.LogError(errorMessage); + result.ErrorMessage = errorMessage; + return; + } + + // Process existence check. (We also re-validate just before binding.) + if (!ProcessExists(parsed)) + { + string errorMessage = $"No running process found with an ID of {parsed}."; Logger.LogError(errorMessage); result.ErrorMessage = errorMessage; } @@ -216,6 +240,25 @@ namespace Awake Manager.CompleteExit(exitCode); } + private static bool ProcessExists(int processId) + { + if (processId <= 0) + { + return false; + } + + try + { + // Throws if the Process ID is not found. + using var p = Process.GetProcessById(processId); + return !p.HasExited; + } + catch + { + return false; + } + } + private static void HandleCommandLineArguments(bool usePtConfig, bool displayOn, uint timeLimit, int pid, string expireAt, bool useParentPid) { if (pid == 0 && !useParentPid) @@ -271,6 +314,12 @@ namespace Awake if (pid != 0) { + if (!ProcessExists(pid)) + { + Logger.LogError($"PID {pid} does not exist or is not accessible. Exiting."); + Exit(Resources.AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE, 1); + } + Logger.LogInfo($"Bound to target process while also using PowerToys settings: {pid}"); RunnerHelper.WaitForPowerToysRunner(pid, () => @@ -287,28 +336,7 @@ namespace Awake } else if (pid != 0 || useParentPid) { - // Second, we snap to process-based execution. Because this is something that - // is snapped to a running entity, we only want to enable the ability to set - // indefinite keep-awake with the display settings that the user wants to set. - // In this context, manual (explicit) PID takes precedence over parent PID. - int targetPid = pid != 0 ? pid : useParentPid ? Manager.GetParentProcess()?.Id ?? 0 : 0; - - if (targetPid != 0) - { - Logger.LogInfo($"Bound to target process: {targetPid}"); - - Manager.SetIndefiniteKeepAwake(displayOn, targetPid); - - RunnerHelper.WaitForPowerToysRunner(targetPid, () => - { - Logger.LogInfo($"Triggered PID-based exit handler for PID {targetPid}."); - Exit(Resources.AWAKE_EXIT_BINDING_HOOK_MESSAGE, 0); - }); - } - else - { - Logger.LogError("Not binding to any process."); - } + HandleProcessScopedKeepAwake(pid, useParentPid, displayOn); } else { @@ -344,6 +372,62 @@ namespace Awake } } + /// + /// Start a process-scoped keep-awake session. The application will keep the system awake + /// indefinitely until the target process terminates. + /// + /// The explicit process ID to monitor. + /// A flag indicating whether the application should monitor its + /// parent process. + /// Whether to keep the display on during the session. + private static void HandleProcessScopedKeepAwake(int pid, bool useParentPid, bool displayOn) + { + int targetPid = 0; + + // We prioritize a user-provided PID over the parent PID. If both are given on the + // command line, the --pid value will be used. + if (pid != 0) + { + if (pid == Environment.ProcessId) + { + Logger.LogError("Awake cannot bind to itself, as this would lead to an indefinite keep-awake state."); + Exit(Resources.AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE, 1); + } + + if (!ProcessExists(pid)) + { + Logger.LogError($"PID {pid} does not exist or is not accessible. Exiting."); + Exit(Resources.AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE, 1); + } + + targetPid = pid; + } + else if (useParentPid) + { + targetPid = Manager.GetParentProcess()?.Id ?? 0; + + if (targetPid == 0) + { + // The parent process could not be identified. + Logger.LogError("Failed to identify a parent process for binding."); + Exit(Resources.AWAKE_EXIT_PARENT_BINDING_FAILURE_MESSAGE, 1); + } + } + + // We have a valid non-zero PID to monitor. + Logger.LogInfo($"Bound to target process: {targetPid}"); + + // Sets the keep-awake plan and updates the tray icon. + Manager.SetIndefiniteKeepAwake(displayOn, targetPid); + + // Synchronize with the target process, and trigger Exit() when it finishes. + RunnerHelper.WaitForPowerToysRunner(targetPid, () => + { + Logger.LogInfo($"Triggered PID-based exit handler for PID {targetPid}."); + Exit(Resources.AWAKE_EXIT_BINDING_HOOK_MESSAGE, 0); + }); + } + private static void AllocateLocalConsole() { Manager.AllocateConsole(); diff --git a/src/modules/awake/Awake/Properties/Resources.Designer.cs b/src/modules/awake/Awake/Properties/Resources.Designer.cs index 905518f6bc..a3aecd2627 100644 --- a/src/modules/awake/Awake/Properties/Resources.Designer.cs +++ b/src/modules/awake/Awake/Properties/Resources.Designer.cs @@ -132,6 +132,15 @@ namespace Awake.Properties { } } + /// + /// Looks up a localized string similar to Exiting because the provided process ID is Awake's own.. + /// + internal static string AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE { + get { + return ResourceManager.GetString("AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE", resourceCulture); + } + } + /// /// Looks up a localized string similar to Terminating from process binding hook.. /// @@ -150,6 +159,24 @@ namespace Awake.Properties { } } + /// + /// Looks up a localized string similar to Exiting because the parent process ID could not be found.. + /// + internal static string AWAKE_EXIT_PARENT_BINDING_FAILURE_MESSAGE { + get { + return ResourceManager.GetString("AWAKE_EXIT_PARENT_BINDING_FAILURE_MESSAGE", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Exiting because the requested process ID could not be found.. + /// + internal static string AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE { + get { + return ResourceManager.GetString("AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE", resourceCulture); + } + } + /// /// Looks up a localized string similar to Received a signal to end the process. Making sure we quit.... /// diff --git a/src/modules/awake/Awake/Properties/Resources.resx b/src/modules/awake/Awake/Properties/Resources.resx index 76ff5b234d..388ca62580 100644 --- a/src/modules/awake/Awake/Properties/Resources.resx +++ b/src/modules/awake/Awake/Properties/Resources.resx @@ -226,4 +226,13 @@ Off + + Exiting because the parent process ID could not be found. + + + Exiting because the requested process ID could not be found. + + + Exiting because the provided process ID is Awake's own. + \ No newline at end of file diff --git a/src/modules/cmdpal/.editorconfig b/src/modules/cmdpal/.editorconfig index f93166a809..281fbeeee7 100644 --- a/src/modules/cmdpal/.editorconfig +++ b/src/modules/cmdpal/.editorconfig @@ -2,28 +2,44 @@ # You can modify the rules from these initially generated values to suit your own policies. # You can learn more about editorconfig here: https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference. -[*.cs] +################################################## +# Global settings +################################################## -file_header_template = Copyright (c) Microsoft Corporation\r\nThe Microsoft Corporation licenses this file to you under the MIT license.\r\nSee the LICENSE file in the project root for more information. - -#Core editorconfig formatting - indentation - -#use soft tabs (spaces) for indentation +[*.{cs,vb}] +tab_width = 4 +indent_size = 4 +end_of_line = crlf indent_style = space +insert_final_newline = true +file_header_template = Copyright (c) Microsoft Corporation\nThe Microsoft Corporation licenses this file to you under the MIT license.\nSee the LICENSE file in the project root for more information. -#Formatting - new line options +################################################## +# C# specific formatting +################################################## + +[*.cs] +# ---------------------------------------------- +# Core editorconfig formatting - indentation +# ---------------------------------------------- #place else statements on a new line csharp_new_line_before_else = true #require braces to be on a new line for lambdas, methods, control_blocks, types, properties, and accessors (also known as "Allman" style) csharp_new_line_before_open_brace = all -#Formatting - organize using options +# ---------------------------------------------- +# Formatting - organize using options +# ---------------------------------------------- -#sort System.* using directives alphabetically, and place them before other usings +# sort System.* using directives alphabetically, and place them before other usings dotnet_sort_system_directives_first = true +# Do not place System.* using directives before other using directives. +dotnet_separate_import_directive_groups = false -#Formatting - spacing options +# ---------------------------------------------- +# Formatting - spacing options +# ---------------------------------------------- #require NO space between a cast and the value csharp_space_after_cast = false @@ -44,17 +60,29 @@ csharp_space_between_method_declaration_empty_parameter_list_parentheses = false #place a space character after the opening parenthesis and before the closing parenthesis of a method declaration parameter list. csharp_space_between_method_declaration_parameter_list_parentheses = false -#Formatting - wrapping options +# ---------------------------------------------- +# Formatting - wrapping options +# ---------------------------------------------- #leave code block on separate lines csharp_preserve_single_line_blocks = true +#put each statement on a separate line +csharp_preserve_single_line_statements = false -#Style - Code block preferences +################################################## +# C# style rules +################################################## + +# ---------------------------------------------- +# Style - Code block preferences +# ---------------------------------------------- #prefer curly braces even for one line of code csharp_prefer_braces = true:suggestion -#Style - expression bodied member options +# ---------------------------------------------- +# Style - expression bodied member options +# ---------------------------------------------- #prefer expression bodies for accessors csharp_style_expression_bodied_accessors = true:warning @@ -65,55 +93,73 @@ csharp_style_expression_bodied_methods = when_on_single_line:silent #prefer expression-bodied members for properties csharp_style_expression_bodied_properties = true:warning -#Style - expression level options +# ---------------------------------------------- +# Style - expression level options +# ---------------------------------------------- #prefer out variables to be declared before the method call csharp_style_inlined_variable_declaration = false:suggestion #prefer the language keyword for member access expressions, instead of the type name, for types that have a keyword to represent them dotnet_style_predefined_type_for_member_access = true:suggestion -#Style - Expression-level preferences +# ---------------------------------------------- +# Style - Expression-level preferences +# ---------------------------------------------- #prefer default over default(T) csharp_prefer_simple_default_expression = true:suggestion #prefer objects to be initialized using object initializers when possible dotnet_style_object_initializer = true:suggestion -#Style - implicit and explicit types +# ---------------------------------------------- +# Style - implicit and explicit types +# ---------------------------------------------- #prefer var over explicit type in all cases, unless overridden by another code style rule csharp_style_var_elsewhere = true:suggestion #prefer var is used to declare variables with built-in system types such as int -csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_for_built_in_types = true:warning #prefer var when the type is already mentioned on the right-hand side of a declaration expression csharp_style_var_when_type_is_apparent = true:suggestion -#Style - language keyword and framework type options +# ---------------------------------------------- +# Style - language keyword and framework type options +# ---------------------------------------------- #prefer the language keyword for local variables, method parameters, and class members, instead of the type name, for types that have a keyword to represent them dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -#Style - Language rules -csharp_style_implicit_object_creation_when_type_is_apparent = true:warning -csharp_style_var_for_built_in_types = true:warning +# ---------------------------------------------- +# Style - Language rules +# ---------------------------------------------- -#Style - modifier options +csharp_style_implicit_object_creation_when_type_is_apparent = true:warning + +# ---------------------------------------------- +# Style - modifier options +# ---------------------------------------------- #prefer accessibility modifiers to be declared except for public interface members. This will currently not differ from always and will act as future proofing for if C# adds default interface methods. dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion -#Style - Modifier preferences +# ---------------------------------------------- +# Style - Modifier preferences +# ---------------------------------------------- #when this rule is set to a list of modifiers, prefer the specified ordering. csharp_preferred_modifier_order = public,private,protected,internal,static,async,readonly,override,sealed,abstract,virtual:warning dotnet_style_readonly_field = true:warning -#Style - Pattern matching +# ---------------------------------------------- +# Style - Pattern matching +# ---------------------------------------------- #prefer pattern matching instead of is expression with type casts csharp_style_pattern_matching_over_as_with_null_check = true:warning -#Style - qualification options +# ---------------------------------------------- +# Style - qualification options +# ---------------------------------------------- #prefer events not to be prefaced with this. or Me. in Visual Basic dotnet_style_qualification_for_event = false:suggestion @@ -123,20 +169,26 @@ dotnet_style_qualification_for_field = false:suggestion dotnet_style_qualification_for_method = false:suggestion #prefer properties not to be prefaced with this. or Me. in Visual Basic dotnet_style_qualification_for_property = false:suggestion -csharp_indent_labels = one_less_than_current -csharp_using_directive_placement = outside_namespace:silent -csharp_prefer_simple_using_statement = true:warning -csharp_style_namespace_declarations = file_scoped:warning + +# ---------------------------------------------- +# Style - expression bodies +# ---------------------------------------------- csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_indexers = true:silent csharp_style_expression_bodied_lambdas = true:silent csharp_style_expression_bodied_local_functions = false:silent +# ---------------------------------------------- +# Style - Miscellaneous preferences +# ---------------------------------------------- + +csharp_indent_labels = one_less_than_current +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:warning +csharp_style_namespace_declarations = file_scoped:warning + [*.{cs,vb}] dotnet_style_operator_placement_when_wrapping = beginning_of_line -tab_width = 4 -indent_size = 4 -end_of_line = crlf dotnet_style_coalesce_expression = true:suggestion dotnet_style_null_propagation = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion @@ -146,12 +198,13 @@ dotnet_style_collection_initializer = true:suggestion dotnet_style_prefer_simplified_boolean_expressions = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:silent dotnet_style_prefer_conditional_expression_over_return = true:silent -[*.{cs,vb}] - -#Style - Unnecessary code rules csharp_style_unused_value_assignment_preference = discard_variable:warning -#### Naming styles #### +################################################## +# Naming rules +################################################## + +[*.{cs,vb}] # Naming rules @@ -203,7 +256,11 @@ dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_compound_assignment = true:warning dotnet_style_prefer_simplified_interpolation = true:suggestion -# Diagnostic configuration +################################################## +# Diagnostics +################################################## + +[*.{cs,vb}] # CS8305: Type is for evaluation purposes only and is subject to change or removal in future updates. dotnet_diagnostic.CS8305.severity = suggestion diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/CoreLogger.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/CoreLogger.cs new file mode 100644 index 0000000000..1863756c75 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/CoreLogger.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.CmdPal.Core.Common; + +public static class CoreLogger +{ + public static void InitializeLogger(ILogger implementation) + { + _logger = implementation; + } + + private static ILogger? _logger; + + public static void LogError(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogError(message, ex, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogError(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogError(message, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogWarning(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogWarning(message, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogInfo(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogInfo(message, memberName, sourceFilePath, sourceLineNumber); + } + + 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) + { + _logger?.LogDebug(message, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogTrace([System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogTrace(memberName, sourceFilePath, sourceLineNumber); + } +} + +public interface ILogger +{ + void LogError(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogError(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogWarning(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogInfo(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + 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/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/DiagnosticsHelper.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/DiagnosticsHelper.cs similarity index 98% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/DiagnosticsHelper.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/DiagnosticsHelper.cs index d2e9ddbcb3..ee28b94ac4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/DiagnosticsHelper.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/DiagnosticsHelper.cs @@ -5,7 +5,7 @@ using System; using System.Runtime.InteropServices; -namespace Microsoft.CmdPal.Common.Helpers; +namespace Microsoft.CmdPal.Core.Common.Helpers; /// /// Provides utility methods for building diagnostic and error messages. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ExtensionHostInstance.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/ExtensionHostInstance.cs similarity index 98% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ExtensionHostInstance.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/ExtensionHostInstance.cs index 76de2729d0..4c1f690635 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ExtensionHostInstance.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/ExtensionHostInstance.cs @@ -7,7 +7,7 @@ using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.Common; +namespace Microsoft.CmdPal.Core.Common; public partial class ExtensionHostInstance { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/InterlockedBoolean.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InterlockedBoolean.cs similarity index 96% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/InterlockedBoolean.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InterlockedBoolean.cs index 8113ef9990..d098c33a8b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/InterlockedBoolean.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InterlockedBoolean.cs @@ -4,7 +4,7 @@ using System.Threading; -namespace Microsoft.CmdPal.Common.Helpers; +namespace Microsoft.CmdPal.Core.Common.Helpers; /// /// Thread-safe boolean implementation using atomic operations diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/NativeEventWaiter.cs similarity index 94% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/NativeEventWaiter.cs index 2344fbb917..df644795f0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/NativeEventWaiter.cs @@ -7,7 +7,7 @@ using System.Threading; using Microsoft.UI.Dispatching; -namespace Microsoft.CmdPal.Common.Helpers; +namespace Microsoft.CmdPal.Core.Common.Helpers; public static partial class NativeEventWaiter { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/SupersedingAsyncGate.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs similarity index 97% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/SupersedingAsyncGate.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs index 9313ba6755..f5e4d3e97b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/SupersedingAsyncGate.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs @@ -6,14 +6,14 @@ using System; using System.Threading; using System.Threading.Tasks; -namespace Microsoft.CmdPal.Common.Helpers; +namespace Microsoft.CmdPal.Core.Common.Helpers; /// /// An async gate that ensures only one operation runs at a time. /// If ExecuteAsync is called while already executing, it cancels the current execution /// and starts the operation again (superseding behavior). /// -public class SupersedingAsyncGate : IDisposable +public sealed partial class SupersedingAsyncGate : IDisposable { private readonly Func _action; private readonly Lock _lock = new(); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.cs new file mode 100644 index 0000000000..4fab6bf194 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.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. + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +/// +/// An async gate that ensures only one value computation runs at a time. +/// If ExecuteAsync is called while already executing, it cancels the current computation +/// and starts the operation again (superseding behavior). +/// Once a value is successfully computed, it is applied (via the provided ). +/// The apply step uses its own lock so that long-running apply logic does not block the +/// computation / superseding pipeline, while still remaining serialized with respect to +/// other apply calls. +/// +/// The type of the computed value. +public sealed partial class SupersedingAsyncValueGate : IDisposable +{ + private readonly Func> _valueFactory; + private readonly Action _apply; + private readonly Lock _lock = new(); // Controls scheduling / superseding + private readonly Lock _applyLock = new(); // Serializes application of results + private int _callId; + private TaskCompletionSource? _currentTcs; + private CancellationTokenSource? _currentCancellationSource; + private Task? _executingTask; + + public SupersedingAsyncValueGate( + Func> valueFactory, + Action apply) + { + ArgumentNullException.ThrowIfNull(valueFactory); + ArgumentNullException.ThrowIfNull(apply); + _valueFactory = valueFactory; + _apply = apply; + } + + /// + /// Executes the configured value computation. If another execution is running, this call will + /// cancel the current execution and restart the computation. The returned task completes when + /// (and only if) the computation associated with this invocation completes (or is canceled / superseded). + /// + /// Optional external cancellation token. + /// The computed value for this invocation. + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + TaskCompletionSource tcs; + + lock (_lock) + { + // Supersede any in-flight computation. + _currentCancellationSource?.Cancel(); + _currentTcs?.TrySetException(new OperationCanceledException("Superseded by newer call")); + + tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + _currentTcs = tcs; + _callId++; + + if (_executingTask is null) + { + _executingTask = Task.Run(ExecuteLoop, CancellationToken.None); + } + } + + using var ctr = cancellationToken.Register(state => ((TaskCompletionSource)state!).TrySetCanceled(cancellationToken), tcs); + return await tcs.Task.ConfigureAwait(false); + } + + private async Task ExecuteLoop() + { + try + { + while (true) + { + TaskCompletionSource? currentTcs; + CancellationTokenSource? currentCts; + int currentCallId; + + lock (_lock) + { + currentTcs = _currentTcs; + currentCallId = _callId; + + if (currentTcs is null) + { + break; // Nothing pending. + } + + _currentCancellationSource?.Dispose(); + _currentCancellationSource = new(); + currentCts = _currentCancellationSource; + } + + try + { + var value = await _valueFactory(currentCts.Token).ConfigureAwait(false); + CompleteSuccessIfCurrent(currentTcs, currentCallId, value); + } + catch (OperationCanceledException) + { + CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetCanceled(currentCts.Token)); + } + catch (Exception ex) + { + CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetException(ex)); + } + } + } + finally + { + lock (_lock) + { + _currentTcs = null; + _currentCancellationSource?.Dispose(); + _currentCancellationSource = null; + _executingTask = null; + } + } + } + + private void CompleteSuccessIfCurrent(TaskCompletionSource candidate, int id, T value) + { + var shouldApply = false; + lock (_lock) + { + if (_currentTcs == candidate && _callId == id) + { + // Mark as consumed so a new computation can start immediately. + _currentTcs = null; + shouldApply = true; + } + } + + if (!shouldApply) + { + return; // Superseded meanwhile. + } + + Exception? applyException = null; + try + { + lock (_applyLock) + { + _apply(value); + } + } + catch (Exception ex) + { + applyException = ex; + } + + if (applyException is null) + { + candidate.TrySetResult(value); + } + else + { + candidate.TrySetException(applyException); + } + } + + private void CompleteIfCurrent( + TaskCompletionSource candidate, + int id, + Action> complete) + { + lock (_lock) + { + if (_currentTcs == candidate && _callId == id) + { + complete(candidate); + _currentTcs = null; + } + } + } + + public void Dispose() + { + lock (_lock) + { + _currentCancellationSource?.Cancel(); + _currentCancellationSource?.Dispose(); + _currentTcs?.TrySetException(new ObjectDisposedException(nameof(SupersedingAsyncValueGate))); + _currentTcs = null; + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/WellKnownKeyChords.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/WellKnownKeyChords.cs similarity index 98% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/WellKnownKeyChords.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/WellKnownKeyChords.cs index 8e481b936c..19364ddbd6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/WellKnownKeyChords.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/WellKnownKeyChords.cs @@ -7,7 +7,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; -namespace Microsoft.CmdPal.Common.Helpers; +namespace Microsoft.CmdPal.Core.Common.Helpers; /// /// Well-known key chords used in the Command Palette and extensions. diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj new file mode 100644 index 0000000000..a6b270799c --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj @@ -0,0 +1,12 @@ + + + + + Microsoft.CmdPal.Core.Common + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/NativeMethods.json b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.json similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/NativeMethods.json rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.json diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionService.cs similarity index 97% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionService.cs index 72ea470e54..bc35a0d284 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionService.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Windows.Foundation; -namespace Microsoft.CmdPal.Common.Services; +namespace Microsoft.CmdPal.Core.Common.Services; public interface IExtensionService { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionWrapper.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionWrapper.cs similarity index 98% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionWrapper.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionWrapper.cs index 61667366ba..3aaf4bdf52 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionWrapper.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionWrapper.cs @@ -8,7 +8,7 @@ using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Windows.ApplicationModel; -namespace Microsoft.CmdPal.Common.Services; +namespace Microsoft.CmdPal.Core.Common.Services; public interface IExtensionWrapper { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IRunHistoryService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs similarity index 69% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IRunHistoryService.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs index 703ad9f1ff..fd68b6e521 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IRunHistoryService.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.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.Collections.Generic; - -namespace Microsoft.CmdPal.Common.Services; +namespace Microsoft.CmdPal.Core.Common.Services; public interface IRunHistoryService { @@ -25,3 +23,12 @@ public interface IRunHistoryService /// The run history item to add. void AddRunHistoryItem(string item); } + +public interface ITelemetryService +{ + void LogRunQuery(string query, int resultCount, ulong durationMs); + + void LogRunCommand(string command, bool asAdmin, bool success); + + void LogOpenUri(string uri, bool isWeb, bool success); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs similarity index 98% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs index 8a93aee51d..125d8d78f4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs @@ -4,7 +4,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; -using ManagedCommon; +using Microsoft.CmdPal.Core.Common; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; @@ -60,7 +60,7 @@ public abstract partial class AppExtensionHost : IExtensionHost return Task.CompletedTask.AsAsyncAction(); } - Logger.LogDebug(message.Message); + CoreLogger.LogDebug(message.Message); _ = Task.Run(() => { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AsyncNavigationRequest.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AsyncNavigationRequest.cs new file mode 100644 index 0000000000..189d566c8a --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AsyncNavigationRequest.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.Core.ViewModels; + +/// +/// Encapsulates a navigation request within Command Palette view models. +/// +/// A view model that should be navigated to. +/// A that can be used to cancel the pending navigation. +public record AsyncNavigationRequest(object? TargetViewModel, CancellationToken NavigationToken); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs similarity index 95% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs index c01cb13730..010432a5e1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs @@ -146,14 +146,13 @@ public partial class CommandBarViewModel : ObservableObject, return ContextKeybindingResult.Unhandled; } + WeakReferenceMessenger.Default.Send(new(command.Command.Model, command.Model)); if (command.HasMoreCommands) { - WeakReferenceMessenger.Default.Send(new(command.Command.Model, command.Model)); return ContextKeybindingResult.KeepOpen; } else { - WeakReferenceMessenger.Default.Send(new(command.Command.Model, command.Model)); return ContextKeybindingResult.Hide; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs similarity index 77% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs index 4b25f68e0a..bc07fca640 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs @@ -39,14 +39,9 @@ public partial class CommandContextItemViewModel(ICommandContextItem contextItem IsCritical = contextItem.IsCritical; - // I actually don't think this will ever actually be null, because - // KeyChord is a struct, which isn't nullable in WinRT - if (contextItem.RequestedShortcut != null) - { - RequestedShortcut = new( - contextItem.RequestedShortcut.Modifiers, - contextItem.RequestedShortcut.Vkey, - contextItem.RequestedShortcut.ScanCode); - } + RequestedShortcut = new( + contextItem.RequestedShortcut.Modifiers, + contextItem.RequestedShortcut.Vkey, + contextItem.RequestedShortcut.ScanCode); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs similarity index 79% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs index 5e2fcea295..b1a977f0de 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Diagnostics.CodeAnalysis; -using ManagedCommon; +using Microsoft.CmdPal.Core.Common; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; @@ -17,7 +17,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa public ExtensionObject Model => _commandItemModel; private readonly ExtensionObject _commandItemModel = new(null); - private CommandContextItemViewModel? _defaultCommandContextItem; + private CommandContextItemViewModel? _defaultCommandContextItemViewModel; internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized; @@ -43,9 +43,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa public string Subtitle { get; private set; } = string.Empty; - private IconInfoViewModel _listItemIcon = new(null); + private IconInfoViewModel _icon = new(null); - public IconInfoViewModel Icon => _listItemIcon.IsSet ? _listItemIcon : Command.Icon; + public IconInfoViewModel Icon => _icon.IsSet ? _icon : Command.Icon; public CommandViewModel Command { get; private set; } @@ -69,9 +69,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa { get { - List l = _defaultCommandContextItem is null ? + List l = _defaultCommandContextItemViewModel is null ? new() : - [_defaultCommandContextItem]; + [_defaultCommandContextItemViewModel]; l.AddRange(MoreCommands); return l; @@ -136,11 +136,11 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa Command.InitializeProperties(); - var listIcon = model.Icon; - if (listIcon is not null) + var icon = model.Icon; + if (icon is not null) { - _listItemIcon = new(listIcon); - _listItemIcon.InitializeProperties(); + _icon = new(icon); + _icon.InitializeProperties(); } // TODO: Do these need to go into FastInit? @@ -184,14 +184,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa MoreCommands = more .Select(item => { - if (item is ICommandContextItem contextItem) - { - return new CommandContextItemViewModel(contextItem, PageContext); - } - else - { - return new SeparatorViewModel(); - } + return item is ICommandContextItem contextItem ? new CommandContextItemViewModel(contextItem, PageContext) : new SeparatorViewModel(); }) .ToList(); } @@ -208,21 +201,19 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa if (!string.IsNullOrEmpty(model.Command?.Name)) { - _defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext) + _defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext) { _itemTitle = Name, Subtitle = Subtitle, Command = Command, // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever + // Anything we set manually here must stay in sync with the corresponding properties on CommandItemViewModel. }; // Only set the icon on the context item for us if our command didn't // have its own icon - if (!Command.HasIcon) - { - _defaultCommandContextItem._listItemIcon = _listItemIcon; - } + UpdateDefaultContextItemIcon(); } Initialized |= InitializedState.SelectionInitialized; @@ -240,12 +231,12 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } catch (Exception ex) { - Logger.LogError("error fast initializing CommandItemViewModel", ex); + CoreLogger.LogError("error fast initializing CommandItemViewModel", ex); Command = new(null, PageContext); _itemTitle = "Error"; Subtitle = "Item failed to load"; MoreCommands = []; - _listItemIcon = _errorIcon; + _icon = _errorIcon; Initialized |= InitializedState.Error; } @@ -262,7 +253,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa catch (Exception ex) { Initialized |= InitializedState.Error; - Logger.LogError("error slow initializing CommandItemViewModel", ex); + CoreLogger.LogError("error slow initializing CommandItemViewModel", ex); } return false; @@ -277,12 +268,12 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } catch (Exception ex) { - Logger.LogError("error initializing CommandItemViewModel", ex); + CoreLogger.LogError("error initializing CommandItemViewModel", ex); Command = new(null, PageContext); _itemTitle = "Error"; Subtitle = "Item failed to load"; MoreCommands = []; - _listItemIcon = _errorIcon; + _icon = _errorIcon; Initialized |= InitializedState.Error; } @@ -312,17 +303,18 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa switch (propertyName) { case nameof(Command): - if (Command is not null) - { - Command.PropertyChanged -= Command_PropertyChanged; - } - + Command.PropertyChanged -= Command_PropertyChanged; Command = new(model.Command, PageContext); Command.InitializeProperties(); // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. _itemTitle = model.Title; + + _defaultCommandContextItemViewModel?.Command = Command; + _defaultCommandContextItemViewModel?.UpdateTitle(_itemTitle); + UpdateDefaultContextItemIcon(); + UpdateProperty(nameof(Name)); UpdateProperty(nameof(Title)); UpdateProperty(nameof(Icon)); @@ -333,12 +325,22 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa break; case nameof(Subtitle): - this.Subtitle = model.Subtitle; + var modelSubtitle = model.Subtitle; + this.Subtitle = modelSubtitle; + _defaultCommandContextItemViewModel?.Subtitle = modelSubtitle; break; case nameof(Icon): - _listItemIcon = new(model.Icon); - _listItemIcon.InitializeProperties(); + var oldIcon = _icon; + _icon = new(model.Icon); + _icon.InitializeProperties(); + if (oldIcon.IsSet || _icon.IsSet) + { + UpdateProperty(nameof(Icon)); + } + + UpdateDefaultContextItemIcon(); + break; case nameof(model.MoreCommands): @@ -348,14 +350,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa var newContextMenu = more .Select(item => { - if (item is ICommandContextItem contextItem) - { - return new CommandContextItemViewModel(contextItem, PageContext); - } - else - { - return new SeparatorViewModel(); - } + return item is ICommandContextItem contextItem ? new CommandContextItemViewModel(contextItem, PageContext) : new SeparatorViewModel(); }) .ToList(); lock (MoreCommands) @@ -392,26 +387,49 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa private void Command_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { var propertyName = e.PropertyName; + var model = _commandItemModel.Unsafe; + if (model is null) + { + return; + } + switch (propertyName) { case nameof(Command.Name): // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. - var model = _commandItemModel.Unsafe; - if (model is not null) - { - _itemTitle = model.Title; - } + _itemTitle = model.Title; + UpdateProperty(nameof(Title), nameof(Name)); - UpdateProperty(nameof(Title)); - UpdateProperty(nameof(Name)); + _defaultCommandContextItemViewModel?.UpdateTitle(model.Command.Name); break; + case nameof(Command.Icon): + UpdateDefaultContextItemIcon(); UpdateProperty(nameof(Icon)); break; } } + private void UpdateDefaultContextItemIcon() + { + // Command icon takes precedence over our icon on the primary command + _defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon); + } + + private void UpdateTitle(string? title) + { + _itemTitle = title ?? string.Empty; + UpdateProperty(nameof(Title)); + } + + private void UpdateIcon(IIconInfo? iconInfo) + { + _icon = new(iconInfo); + _icon.InitializeProperties(); + UpdateProperty(nameof(Icon)); + } + protected override void UnsafeCleanup() { base.UnsafeCleanup(); @@ -425,10 +443,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } // _listItemIcon.SafeCleanup(); - _listItemIcon = new(null); // necessary? + _icon = new(null); // necessary? - _defaultCommandContextItem?.SafeCleanup(); - _defaultCommandContextItem = null; + _defaultCommandContextItemViewModel?.SafeCleanup(); + _defaultCommandContextItemViewModel = null; Command.PropertyChanged -= Command_PropertyChanged; Command.SafeCleanup(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ConfirmResultViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ConfirmResultViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ConfirmResultViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ConfirmResultViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs similarity index 99% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs index 9665908474..b30d02ce83 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs @@ -14,7 +14,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Core.ViewModels; -public abstract partial class ContentPageViewModel : PageViewModel, ICommandBarContext +public partial class ContentPageViewModel : PageViewModel, ICommandBarContext { private readonly ExtensionObject _model; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs similarity index 87% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs index cd2143200a..0263f20464 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs @@ -5,7 +5,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; -using ManagedCommon; +using Microsoft.CmdPal.Core.Common; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -83,7 +83,7 @@ public partial class ContextMenuViewModel : ObservableObject, if (string.IsNullOrEmpty(searchText)) { - ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu]); + ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu); return; } @@ -107,11 +107,11 @@ public partial class ContextMenuViewModel : ObservableObject, return 0; } - var nameMatch = StringMatcher.FuzzySearch(query, item.Title); + var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Title); - var descriptionMatch = StringMatcher.FuzzySearch(query, item.Subtitle); + var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Subtitle); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max(); } /// @@ -141,7 +141,7 @@ public partial class ContextMenuViewModel : ObservableObject, var added = result.TryAdd(key, cmd); if (!added) { - Logger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'"); + CoreLogger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'"); } } } @@ -155,12 +155,7 @@ public partial class ContextMenuViewModel : ObservableObject, // Does the pressed key match any of the keybindings? var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); - if (keybindings.TryGetValue(pressedKeyChord, out var item)) - { - return InvokeCommand(item); - } - - return null; + return keybindings.TryGetValue(pressedKeyChord, out var item) ? InvokeCommand(item) : null; } public bool CanPopContextStack() @@ -178,7 +173,7 @@ public partial class ContextMenuViewModel : ObservableObject, OnPropertyChanging(nameof(CurrentContextMenu)); OnPropertyChanged(nameof(CurrentContextMenu)); - ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu!]); + ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!); } private void PushContextStack(IEnumerable commands) @@ -187,7 +182,7 @@ public partial class ContextMenuViewModel : ObservableObject, OnPropertyChanging(nameof(CurrentContextMenu)); OnPropertyChanged(nameof(CurrentContextMenu)); - ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu!]); + ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!); } public void ResetContextMenu() @@ -202,7 +197,7 @@ public partial class ContextMenuViewModel : ObservableObject, if (CurrentContextMenu is not null) { - ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu!]); + ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsCommandsViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsCommandsViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsCommandsViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsCommandsViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsDataViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsDataViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsDataViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsDataViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsElementViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsElementViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsElementViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsElementViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsSeparatorViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsSeparatorViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsSeparatorViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsSeparatorViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsTagsViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsTagsViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsTagsViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsTagsViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs similarity index 92% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs index 1c056c4806..e7bed46db6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.ComponentModel; -using ManagedCommon; +using Microsoft.CmdPal.Core.Common; namespace Microsoft.CmdPal.Core.ViewModels; @@ -11,13 +11,13 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject { public WeakReference PageContext { get; set; } - public ExtensionObjectViewModel(IPageContext? context) + internal ExtensionObjectViewModel(IPageContext? context) { var realContext = context ?? (this is IPageContext c ? c : throw new ArgumentException("You need to pass in an IErrorContext")); PageContext = new(realContext); } - public ExtensionObjectViewModel(WeakReference context) + internal ExtensionObjectViewModel(WeakReference context) { PageContext = context; } @@ -114,7 +114,7 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject } catch (Exception ex) { - Logger.LogDebug(ex.ToString()); + CoreLogger.LogDebug(ex.ToString()); } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs similarity index 96% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs index 78fdb26286..45ea3e8a3a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs @@ -9,7 +9,7 @@ namespace Microsoft.CmdPal.Core.ViewModels; public partial class FilterItemViewModel : ExtensionObjectViewModel, IFilterItemViewModel { - private ExtensionObject _model; + private readonly ExtensionObject _model; public string Id { get; set; } = string.Empty; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/GlobalLogPageContext.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GlobalLogPageContext.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/GlobalLogPageContext.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GlobalLogPageContext.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IRootPageService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IRootPageService.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IRootPageService.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IRootPageService.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IconDataViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconDataViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IconDataViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconDataViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IconInfoViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconInfoViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IconInfoViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconInfoViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs similarity index 56% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs index 43dc24f72f..8850d5778b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Diagnostics.CodeAnalysis; +using Microsoft.CmdPal.Core.ViewModels.Commands; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -29,6 +30,8 @@ public partial class ListItemViewModel(IListItem model, WeakReference Details is not null; + public string AccessibleName { get; private set; } = string.Empty; + public override void InitializeProperties() { if (IsInitialized) @@ -50,6 +53,8 @@ public partial class ListItemViewModel(IListItem model, WeakReference StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success; - public override string ToString() => $"{Name} ListItemViewModel"; public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model); public override int GetHashCode() => Model.GetHashCode(); + private void AddShowDetailsCommands() + { + // If the parent page has ShowDetails = false and we have details, + // then we should add a show details action in the context menu. + if (HasDetails && + PageContext.TryGetTarget(out var pageContext) && + pageContext is ListViewModel listViewModel && + !listViewModel.ShowDetails) + { + // Check if "Show Details" action already exists to prevent duplicates + if (!MoreCommands.Any(cmd => cmd is CommandContextItemViewModel contextItemViewModel && + contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId)) + { + // Create the view model for the show details command + var showDetailsCommand = new ShowDetailsCommand(Details); + var showDetailsContextItem = new CommandContextItem(showDetailsCommand); + var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext); + showDetailsContextItemViewModel.SlowInitializeProperties(); + MoreCommands.Add(showDetailsContextItemViewModel); + } + + UpdateProperty(nameof(MoreCommands)); + UpdateProperty(nameof(AllCommands)); + } + } + + // This method is called when the details change to make sure we + // have the latest details in the show details command. + private void UpdateShowDetailsCommand() + { + // If the parent page has ShowDetails = false and we have details, + // then we should add a show details action in the context menu. + if (HasDetails && + PageContext.TryGetTarget(out var pageContext) && + pageContext is ListViewModel listViewModel && + !listViewModel.ShowDetails) + { + var existingCommand = MoreCommands.FirstOrDefault(cmd => + cmd is CommandContextItemViewModel contextItemViewModel && + contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId); + + // If the command already exists, remove it to update with the new details + if (existingCommand is not null) + { + MoreCommands.Remove(existingCommand); + } + + // Create the view model for the show details command + var showDetailsCommand = new ShowDetailsCommand(Details); + var showDetailsContextItem = new CommandContextItem(showDetailsCommand); + var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext); + showDetailsContextItemViewModel.SlowInitializeProperties(); + MoreCommands.Add(showDetailsContextItemViewModel); + + UpdateProperty(nameof(MoreCommands)); + UpdateProperty(nameof(AllCommands)); + } + } + private void UpdateTags(ITag[]? newTagsFromModel) { var newTags = newTagsFromModel?.Select(t => @@ -132,7 +204,7 @@ public partial class ListItemViewModel(IListItem model, WeakReference _itemCache = []; + private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler); // TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items? @@ -68,9 +69,16 @@ public partial class ListViewModel : PageViewModel, IDisposable private bool _isDynamic; private Task? _initializeItemsTask; + + // For cancelling the task to load the properties from the items in the list private CancellationTokenSource? _cancellationTokenSource; + + // For cancelling the task for calling GetItems on the extension private CancellationTokenSource? _fetchItemsCancellationTokenSource; + // For cancelling ongoing calls to update the extension's SearchText + private CancellationTokenSource? filterCancellationTokenSource; + private ListItemViewModel? _lastSelectedItem; public override bool IsInitialized @@ -102,10 +110,20 @@ public partial class ListViewModel : PageViewModel, IDisposable // something needs to change, by raising ItemsChanged. if (_isDynamic) { - // We're getting called on the UI thread. - // Hop off to a BG thread to update the extension. - _ = Task.Run(() => + filterCancellationTokenSource?.Cancel(); + filterCancellationTokenSource?.Dispose(); + filterCancellationTokenSource = new CancellationTokenSource(); + + // Hop off to an exclusive scheduler background thread to update the + // extension. We do this to ensure that all filter update requests + // are serialized and in-order, so providers know to cancel previous + // requests when a new one comes in. Otherwise, they may execute + // concurrently. + _ = filterTaskFactory.StartNew( + () => { + filterCancellationTokenSource.Token.ThrowIfCancellationRequested(); + try { if (_model.Unsafe is IDynamicListPage dynamic) @@ -113,11 +131,17 @@ public partial class ListViewModel : PageViewModel, IDisposable dynamic.SearchText = searchTextBox; } } + catch (OperationCanceledException) + { + } catch (Exception ex) { ShowException(ex, _model?.Unsafe?.Name); } - }); + }, + filterCancellationTokenSource.Token, + TaskCreationOptions.None, + filterTaskFactory.Scheduler!); } else { @@ -173,12 +197,18 @@ public partial class ListViewModel : PageViewModel, IDisposable try { // Check for cancellation before starting expensive operations - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } var newItems = _model.Unsafe!.GetItems(); // Check for cancellation after getting items from extension - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } // TODO we can probably further optimize this by also keeping a // HashSet of every ExtensionObject we currently have, and only @@ -186,7 +216,10 @@ public partial class ListViewModel : PageViewModel, IDisposable foreach (var item in newItems) { // Check for cancellation during item processing - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } ListItemViewModel viewModel = new(item, new(this)); @@ -198,12 +231,19 @@ public partial class ListViewModel : PageViewModel, IDisposable } // Check for cancellation before initializing first twenty items - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } var firstTwenty = newViewModels.Take(20); foreach (var item in firstTwenty) { - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } + item?.SafeInitializeProperties(); } @@ -211,7 +251,10 @@ public partial class ListViewModel : PageViewModel, IDisposable _cancellationTokenSource?.Cancel(); // Check for cancellation before updating the list - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } List removedItems = []; lock (_listLock) @@ -264,13 +307,7 @@ public partial class ListViewModel : PageViewModel, IDisposable _initializeItemsTask = new Task(() => { - try - { - InitializeItemsTask(_cancellationTokenSource.Token); - } - catch (OperationCanceledException) - { - } + InitializeItemsTask(_cancellationTokenSource.Token); }); _initializeItemsTask.Start(); @@ -304,7 +341,10 @@ public partial class ListViewModel : PageViewModel, IDisposable private void InitializeItemsTask(CancellationToken ct) { // Were we already canceled? - ct.ThrowIfCancellationRequested(); + if (ct.IsCancellationRequested) + { + return; + } ListItemViewModel[] iterable; lock (_listLock) @@ -314,7 +354,10 @@ public partial class ListViewModel : PageViewModel, IDisposable foreach (var item in iterable) { - ct.ThrowIfCancellationRequested(); + if (ct.IsCancellationRequested) + { + return; + } // TODO: GH #502 // We should probably remove the item from the list if it @@ -323,7 +366,10 @@ public partial class ListViewModel : PageViewModel, IDisposable // at once. item.SafeInitializeProperties(); - ct.ThrowIfCancellationRequested(); + if (ct.IsCancellationRequested) + { + return; + } } } @@ -345,9 +391,9 @@ public partial class ListViewModel : PageViewModel, IDisposable return 1; } - var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title); - var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title); + var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle); + return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max(); } private struct ScoredListItemViewModel @@ -675,6 +721,10 @@ public partial class ListViewModel : PageViewModel, IDisposable _cancellationTokenSource?.Dispose(); _cancellationTokenSource = null; + filterCancellationTokenSource?.Cancel(); + filterCancellationTokenSource?.Dispose(); + filterCancellationTokenSource = null; + _fetchItemsCancellationTokenSource?.Cancel(); _fetchItemsCancellationTokenSource?.Dispose(); _fetchItemsCancellationTokenSource = null; @@ -688,6 +738,7 @@ public partial class ListViewModel : PageViewModel, IDisposable EmptyContent = new(new(null), PageContext); // necessary? _cancellationTokenSource?.Cancel(); + filterCancellationTokenSource?.Cancel(); _fetchItemsCancellationTokenSource?.Cancel(); lock (_listLock) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/LogMessageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LogMessageViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/LogMessageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LogMessageViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSecondaryCommandMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSecondaryCommandMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSecondaryCommandMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSecondaryCommandMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSelectedListItemMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSelectedListItemMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSelectedListItemMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSelectedListItemMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/BeginInvokeMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/BeginInvokeMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/BeginInvokeMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/BeginInvokeMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ClearSearchMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ClearSearchMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ClearSearchMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ClearSearchMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/CloseContextMenuMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CloseContextMenuMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/CloseContextMenuMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CloseContextMenuMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/CmdPalInvokeResultMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CmdPalInvokeResultMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/CmdPalInvokeResultMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CmdPalInvokeResultMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/DismissMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/DismissMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/DismissMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/DismissMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/FocusSearchBoxMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/FocusSearchBoxMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/FocusSearchBoxMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/FocusSearchBoxMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/GoBackMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoBackMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/GoBackMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoBackMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/GoHomeMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoHomeMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/GoHomeMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoHomeMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/HandleCommandResultMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HandleCommandResultMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/HandleCommandResultMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HandleCommandResultMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/HideDetailsMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HideDetailsMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/HideDetailsMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HideDetailsMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/LaunchUriMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/LaunchUriMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/LaunchUriMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/LaunchUriMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateBackMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateBackMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateBackMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateBackMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.cs similarity index 86% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.cs index 4ad61b20e6..88ee239c95 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.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. -namespace Microsoft.CmdPal.Core.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Commands; /// /// Used to navigate to the next command in the page when pressing the Down key in the SearchBox. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePreviousCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePreviousCommand.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePreviousCommand.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePreviousCommand.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.cs similarity index 85% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.cs index 3d17f3b3cc..a9318a88d7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.cs @@ -4,6 +4,4 @@ namespace Microsoft.CmdPal.Core.ViewModels.Messages; -public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation) -{ -} +public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ShowConfirmationMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowConfirmationMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ShowConfirmationMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowConfirmationMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ShowDetailsMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowDetailsMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ShowDetailsMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowDetailsMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ShowToastMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowToastMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ShowToastMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowToastMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ShowWindowMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowWindowMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/ShowWindowMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowWindowMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/TryCommandKeybindingMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TryCommandKeybindingMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/TryCommandKeybindingMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TryCommandKeybindingMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs similarity index 91% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs index 8fed920750..ad99b25933 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System.ComponentModel; -using ManagedCommon; +using Microsoft.CmdPal.Core.Common; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -50,7 +50,7 @@ public interface IContextMenuContext : INotifyPropertyChanged var added = result.TryAdd(key, cmd); if (!added) { - Logger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'"); + CoreLogger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'"); } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj new file mode 100644 index 0000000000..4ace6c5783 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + True + True + Resources.resx + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Models/ExtensionObject`1.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Models/ExtensionObject`1.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Models/ExtensionObject`1.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Models/ExtensionObject`1.cs diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.json b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.json new file mode 100644 index 0000000000..59fa7259c4 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/NativeMethods.txt b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.txt similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/NativeMethods.txt rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.txt diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs new file mode 100644 index 0000000000..504eef6af1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.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. + +namespace Microsoft.CmdPal.Core.ViewModels; + +internal sealed partial class NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost) + : PageViewModel(null, scheduler, extensionHost); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs similarity index 98% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index 5c445615be..82b4b7d59e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -5,7 +5,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Microsoft.CmdPal.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; @@ -68,6 +68,8 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext // `IsLoading` property as a combo of this value and `IsInitialized` public bool ModelIsLoading { get; protected set; } = true; + public bool HasSearchBox { get; protected set; } + public IconInfoViewModel Icon { get; protected set; } public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost) @@ -144,12 +146,15 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext Icon = new(page.Icon); Icon.InitializeProperties(); + HasSearchBox = page is IListPage; + // Let the UI know about our initial properties too. UpdateProperty(nameof(Name)); UpdateProperty(nameof(Title)); UpdateProperty(nameof(ModelIsLoading)); UpdateProperty(nameof(IsLoading)); UpdateProperty(nameof(Icon)); + UpdateProperty(nameof(HasSearchBox)); page.PropChanged += Model_PropChanged; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ProgressViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ProgressViewModel.cs similarity index 100% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ProgressViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ProgressViewModel.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Properties/Resources.Designer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.Designer.cs similarity index 63% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Properties/Resources.Designer.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.Designer.cs index c2f81dd683..ebfc32b862 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.Designer.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -namespace Microsoft.CmdPal.Common.Properties { +namespace Microsoft.CmdPal.Core.ViewModels.Properties { using System; @@ -22,7 +22,7 @@ namespace Microsoft.CmdPal.Common.Properties { [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 { + public class Resources { private static global::System.Resources.ResourceManager resourceMan; @@ -36,10 +36,10 @@ namespace Microsoft.CmdPal.Common.Properties { /// 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 { + public static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Common.Properties.Resources", typeof(Resources).Assembly); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Core.ViewModels.Properties.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; @@ -51,7 +51,7 @@ namespace Microsoft.CmdPal.Common.Properties { /// resource lookups using this strongly typed resource class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { + public static global::System.Globalization.CultureInfo Culture { get { return resourceCulture; } @@ -61,38 +61,11 @@ namespace Microsoft.CmdPal.Common.Properties { } /// - /// Looks up a localized string similar to Open path in console. + /// Looks up a localized string similar to Show details. /// - internal static string Indexer_Command_OpenPathInConsole { + public static string ShowDetailsCommand { get { - return ResourceManager.GetString("Indexer_Command_OpenPathInConsole", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Properties. - /// - internal static string Indexer_Command_OpenProperties { - get { - return ResourceManager.GetString("Indexer_Command_OpenProperties", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Open with. - /// - internal static string Indexer_Command_OpenWith { - get { - return ResourceManager.GetString("Indexer_Command_OpenWith", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Show in folder. - /// - internal static string Indexer_Command_ShowInFolder { - get { - return ResourceManager.GetString("Indexer_Command_ShowInFolder", resourceCulture); + return ResourceManager.GetString("ShowDetailsCommand", resourceCulture); } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Properties/Resources.resx b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx similarity index 92% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Properties/Resources.resx rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx index 14e62fb4c2..560907942b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Properties/Resources.resx +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx @@ -1,4 +1,4 @@ - + preview $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\ false false - Microsoft.CmdPal.Common.pri + $(RootNamespace).pri + + + SA1313; + + enable + + + + true - - - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -28,24 +33,14 @@ - - - - - - - - True - True - Resources.resx - + - ResXFileCodeGenerator + PublicResXFileCodeGenerator Resources.Designer.cs - + diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props index 75ffb75e31..4722a0974e 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props @@ -3,12 +3,13 @@ true - + + diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj index ae3035a498..7d83967705 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj @@ -40,10 +40,13 @@ - - - + + + + all + runtime; build; native; contentfiles; analyzers + true + + + false diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs deleted file mode 100644 index 342667cf83..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs +++ /dev/null @@ -1,56 +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.Security.Principal; -using Windows.Win32; -using Windows.Win32.Foundation; - -namespace Microsoft.CmdPal.Common.Helpers; - -public static partial class RuntimeHelper -{ - public static bool IsMSIX - { - get - { - // TODO: for whatever reason, when I ported this into the PT - // codebase, this no longer compiled. We're only ever using it for - // the hacked up settings and ignoring it anyways, so I'm leaving - // it commented out for now. - // - // See also: - // * https://github.com/microsoft/win32metadata/commit/6fee67ba73bfe1b126ce524f7de8d367f0317715 - // * https://github.com/microsoft/win32metadata/issues/1311 - // uint length = 0; - // return PInvoke.GetCurrentPackageFullName(ref length, null) != WIN32_ERROR.APPMODEL_ERROR_NO_PACKAGE; -#pragma warning disable IDE0025 // Use expression body for property - return true; -#pragma warning restore IDE0025 // Use expression body for property - } - } - - public static bool IsOnWindows11 - { - get - { - var version = Environment.OSVersion.Version; - return version.Major >= 10 && version.Build >= 22000; - } - } - - public static bool IsCurrentProcessRunningAsAdmin() - { - var identity = WindowsIdentity.GetCurrent(); - return identity.Owner?.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid) ?? false; - } - - public static void VerifyCurrentProcessRunningAsAdmin() - { - if (!IsCurrentProcessRunningAsAdmin()) - { - throw new UnauthorizedAccessException("This operation requires elevated privileges."); - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj deleted file mode 100644 index 014a2a39e1..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - enable - enable - false - false - $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal - - preview - - SA1313; - - - - true - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - - - - - - - - Resources.resx - True - True - - - - diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip index f026bbba8b..7bfe8ce57b 100644 Binary files a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip and b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip differ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs index ab507950c5..da0972de8e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.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.Core.ViewModels; using Microsoft.CommandPalette.Extensions; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index 00cbe03d48..c59c90814b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using ManagedCommon; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs index 2c2eafc44c..fe440cdaa7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.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 ManagedCommon; +using Microsoft.CmdPal.Core.Common; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; @@ -44,7 +44,7 @@ public partial class CommandSettingsViewModel(ICommandSettings? _unsafeSettings, } catch (Exception ex) { - Logger.LogError($"Failed to load settings page", ex: ex); + CoreLogger.LogError($"Failed to load settings page", ex: ex); } Initialized = true; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltinsExtensionHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltinsExtensionHost.cs index 9b8bd8ed48..c046d0c21a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltinsExtensionHost.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltinsExtensionHost.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; +using Microsoft.CmdPal.Core.Common; namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 2dfb934f1c..bd767ba0f1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -4,12 +4,14 @@ using System.Collections.Immutable; using System.Collections.Specialized; +using System.Diagnostics; using CommunityToolkit.Mvvm.Messaging; using ManagedCommon; -using Microsoft.CmdPal.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Ext.Apps; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Extensions.DependencyInjection; @@ -22,22 +24,30 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage; /// public partial class MainListPage : DynamicListPage, IRecipient, - IRecipient + IRecipient, IDisposable { - private readonly IServiceProvider _serviceProvider; + private readonly string[] _specialFallbacks = [ + "com.microsoft.cmdpal.builtin.run", + "com.microsoft.cmdpal.builtin.calculator" + ]; + private readonly IServiceProvider _serviceProvider; private readonly TopLevelCommandManager _tlcManager; - private IEnumerable>? _filteredItems; - private IEnumerable>? _filteredApps; - private IEnumerable? _allApps; + private List>? _filteredItems; + private List>? _filteredApps; + private List>? _fallbackItems; private bool _includeApps; private bool _filteredItemsIncludesApps; + private int _appResultLimit = 10; private InterlockedBoolean _refreshRunning; private InterlockedBoolean _refreshRequested; + private CancellationTokenSource? _cancellationTokenSource; + public MainListPage(IServiceProvider serviceProvider) { + Title = Resources.builtin_home_name; Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png"); PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder; _serviceProvider = serviceProvider; @@ -50,12 +60,12 @@ public partial class MainListPage : DynamicListPage, // We just want to know when it is done. var allApps = AllAppsCommandProvider.Page; allApps.PropChanged += (s, p) => - { - if (p.PropertyName == nameof(allApps.IsLoading)) { - IsLoading = ActuallyLoading(); - } - }; + if (p.PropertyName == nameof(allApps.IsLoading)) + { + IsLoading = ActuallyLoading(); + } + }; WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -150,10 +160,23 @@ public partial class MainListPage : DynamicListPage, { lock (_tlcManager.TopLevelCommands) { + List> limitedApps = new List>(); + + // Fuzzy matching can produce a lot of results, so we want to limit the + // number of apps we show at once if it's a large set. + if (_filteredApps?.Count > 0) + { + limitedApps = _filteredApps.OrderByDescending(s => s.Score).Take(_appResultLimit).ToList(); + } + var items = Enumerable.Empty>() .Concat(_filteredItems is not null ? _filteredItems : []) - .Concat(_filteredApps is not null ? _filteredApps : []) + .Concat(limitedApps) .OrderByDescending(o => o.Score) + + // Add fallback items post-sort so they are always at the end of the list + // and eventually ordered based on user preference + .Concat(_fallbackItems is not null ? _fallbackItems.Where(w => !string.IsNullOrEmpty(w.Item.Title)) : []) .Select(s => s.Item) .ToArray(); return items; @@ -163,10 +186,29 @@ public partial class MainListPage : DynamicListPage, public override void UpdateSearchText(string oldSearch, string newSearch) { + var timer = new Stopwatch(); + timer.Start(); + + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = new CancellationTokenSource(); + + var token = _cancellationTokenSource.Token; + if (token.IsCancellationRequested) + { + return; + } + // Handle changes to the filter text here if (!string.IsNullOrEmpty(SearchText)) { var aliases = _serviceProvider.GetService()!; + + if (token.IsCancellationRequested) + { + return; + } + if (aliases.CheckAlias(newSearch)) { if (_filteredItemsIncludesApps != _includeApps) @@ -176,7 +218,6 @@ public partial class MainListPage : DynamicListPage, _filteredItemsIncludesApps = _includeApps; _filteredItems = null; _filteredApps = null; - _allApps = null; } } @@ -184,10 +225,20 @@ public partial class MainListPage : DynamicListPage, } } + if (token.IsCancellationRequested) + { + return; + } + var commands = _tlcManager.TopLevelCommands; lock (commands) { - UpdateFallbacks(newSearch, commands.ToImmutableArray()); + UpdateFallbacks(SearchText, commands.ToImmutableArray(), token); + + if (token.IsCancellationRequested) + { + return; + } // Cleared out the filter text? easy. Reset _filteredItems, and bail out. if (string.IsNullOrEmpty(newSearch)) @@ -195,7 +246,7 @@ public partial class MainListPage : DynamicListPage, _filteredItemsIncludesApps = _includeApps; _filteredItems = null; _filteredApps = null; - _allApps = null; + _fallbackItems = null; RaiseItemsChanged(commands.Count); return; } @@ -206,7 +257,7 @@ public partial class MainListPage : DynamicListPage, { _filteredItems = null; _filteredApps = null; - _allApps = null; + _fallbackItems = null; } // If the internal state has changed, reset _filteredItems to reset the list. @@ -214,61 +265,154 @@ public partial class MainListPage : DynamicListPage, { _filteredItems = null; _filteredApps = null; - _allApps = null; + _fallbackItems = null; } - var newFilteredItems = _filteredItems?.Select(s => s.Item); + if (token.IsCancellationRequested) + { + return; + } + + IEnumerable newFilteredItems = Enumerable.Empty(); + IEnumerable newFallbacks = Enumerable.Empty(); + IEnumerable newApps = Enumerable.Empty(); + + if (_filteredItems is not null) + { + newFilteredItems = _filteredItems.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; + } + + if (_filteredApps is not null) + { + newApps = _filteredApps.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; + } + + if (_fallbackItems is not null) + { + newFallbacks = _fallbackItems.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; + } // If we don't have any previous filter results to work with, start // with a list of all our commands & apps. - if (newFilteredItems is null && _filteredApps is null) + if (!newFilteredItems.Any() && !newApps.Any()) { - newFilteredItems = commands; + // We're going to start over with our fallbacks + newFallbacks = Enumerable.Empty(); + + newFilteredItems = commands.Where(s => !s.IsFallback || _specialFallbacks.Contains(s.CommandProviderId)); + + // Fallbacks are always included in the list, even if they + // don't match the search text. But we don't want to + // consider them when filtering the list. + newFallbacks = commands.Where(s => s.IsFallback && !_specialFallbacks.Contains(s.CommandProviderId)); + + if (token.IsCancellationRequested) + { + return; + } + _filteredItemsIncludesApps = _includeApps; if (_includeApps) { - _allApps = AllAppsCommandProvider.Page.GetItems(); + newApps = AllAppsCommandProvider.Page.GetItems().ToList(); + } + + if (token.IsCancellationRequested) + { + return; } } // Produce a list of everything that matches the current filter. - _filteredItems = ListHelpers.FilterListWithScores(newFilteredItems ?? [], SearchText, ScoreTopLevelItem); + _filteredItems = [.. ListHelpers.FilterListWithScores(newFilteredItems ?? [], SearchText, ScoreTopLevelItem)]; + + if (token.IsCancellationRequested) + { + return; + } + + // Defaulting scored to 1 but we'll eventually use user rankings + _fallbackItems = [.. newFallbacks.Select(f => new Scored { Item = f, Score = 1 })]; + + if (token.IsCancellationRequested) + { + return; + } // Produce a list of filtered apps with the appropriate limit - if (_allApps is not null) + if (newApps.Any()) { - _filteredApps = ListHelpers.FilterListWithScores(_allApps, SearchText, ScoreTopLevelItem); + var scoredApps = ListHelpers.FilterListWithScores(newApps, SearchText, ScoreTopLevelItem); - var appResultLimit = AllAppsCommandProvider.TopLevelResultLimit; - if (appResultLimit >= 0) + if (token.IsCancellationRequested) { - _filteredApps = _filteredApps.Take(appResultLimit); + return; + } + + // We'll apply this limit in the GetItems method after merging with commands + // but we need to know the limit now to avoid re-scoring apps + var appLimit = AllAppsCommandProvider.TopLevelResultLimit; + + _filteredApps = [.. scoredApps]; + + if (token.IsCancellationRequested) + { + return; } } RaiseItemsChanged(); + + timer.Stop(); + Logger.LogDebug($"Filter with '{newSearch}' in {timer.ElapsedMilliseconds}ms"); } } - private void UpdateFallbacks(string newSearch, IReadOnlyList commands) + private void UpdateFallbacks(string newSearch, IReadOnlyList commands, CancellationToken token) { - // fire and forget - _ = Task.Run(() => + _ = Task.Run( + () => { var needsToUpdate = false; foreach (var command in commands) { + if (token.IsCancellationRequested) + { + return; + } + var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch); needsToUpdate = needsToUpdate || changedVisibility; } if (needsToUpdate) { + if (token.IsCancellationRequested) + { + return; + } + RaiseItemsChanged(); } - }); + }, + token); } private bool ActuallyLoading() @@ -322,19 +466,19 @@ public partial class MainListPage : DynamicListPage, // * otherwise full weight match var nameMatch = isWhiteSpace ? (title.Contains(query) ? 1 : 0) : - StringMatcher.FuzzySearch(query, title).Score; + FuzzyStringMatcher.ScoreFuzzy(query, title); // Subtitle: // * whitespace query: 1/2 point // * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer var descriptionMatch = isWhiteSpace ? (topLevelOrAppItem.Subtitle.Contains(query) ? .5 : 0) : - (StringMatcher.FuzzySearch(query, topLevelOrAppItem.Subtitle).Score - 4) / 2.0; + (FuzzyStringMatcher.ScoreFuzzy(query, topLevelOrAppItem.Subtitle) - 4) / 2.0; // Extension title: despite not being visible, give the extension name itself some weight // * whitespace query: 0 points // * otherwise more weight than a subtitle, but not much - var extensionTitleMatch = isWhiteSpace ? 0 : StringMatcher.FuzzySearch(query, extensionDisplayName).Score / 1.5; + var extensionTitleMatch = isWhiteSpace ? 0 : FuzzyStringMatcher.ScoreFuzzy(query, extensionDisplayName) / 1.5; var scores = new[] { @@ -397,4 +541,22 @@ public partial class MainListPage : DynamicListPage, private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender); private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails; + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + + _tlcManager.PropertyChanged -= TlcManager_PropertyChanged; + _tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged; + + var settings = _serviceProvider.GetService(); + if (settings is not null) + { + settings.SettingsChanged -= SettingsChangedHandler; + } + + WeakReferenceMessenger.Default.UnregisterAll(this); + GC.SuppressFinalize(this); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs index 6b6a579207..cafb351d21 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs @@ -25,7 +25,7 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference Root => [RootContent]; + public ObservableCollection Root => RootContent is not null ? [RootContent] : []; public override void InitializeProperties() { @@ -122,7 +122,7 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference - - + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs index f0a14ab7db..d92068c71d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using ManagedCommon; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CommandPalette.Extensions; using Windows.ApplicationModel; using Windows.ApplicationModel.AppExtensions; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs index 32526c6544..8d9f8eddb5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs @@ -4,7 +4,7 @@ using System.Runtime.InteropServices; using ManagedCommon; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CommandPalette.Extensions; using Windows.ApplicationModel; using Windows.ApplicationModel.AppExtensions; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.txt index 981c7446f7..77bf209218 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.txt @@ -17,3 +17,7 @@ SHCreateStreamOnFileEx CoAllowSetForegroundWindow SHCreateStreamOnFileEx SHLoadIndirectString +CoCancelCall +CoEnableCallCancellation +CoDisableCallCancellation +GetCurrentThreadId \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs index 6924e48d75..e3a4e31088 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs @@ -285,6 +285,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } + /// + /// Looks up a localized string similar to Home. + /// + public static string builtin_home_name { + get { + return ResourceManager.GetString("builtin_home_name", resourceCulture); + } + } + /// /// Looks up a localized string similar to View log folder. /// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx index a69fa5b00e..6279a0bfdc 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx @@ -233,4 +233,7 @@ Search for apps, files and commands... + + Home + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs index 714b3ca805..52b2bea003 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs @@ -5,7 +5,7 @@ using System.Diagnostics.CodeAnalysis; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Properties; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index 582a207795..39d0bcf562 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -52,6 +52,8 @@ public partial class SettingsModel : ObservableObject public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse; + public bool DisableAnimations { get; set; } = true; + // END SETTINGS /////////////////////////////////////////////////////////////////////////// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 7b7463a175..8e05660656 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -128,6 +128,16 @@ public partial class SettingsViewModel : INotifyPropertyChanged } } + public bool DisableAnimations + { + get => _settings.DisableAnimations; + set + { + _settings.DisableAnimations = value; + Save(); + } + } + public ObservableCollection CommandProviders { get; } = []; public SettingsViewModel(SettingsModel settings, IServiceProvider serviceProvider, TaskScheduler scheduler) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index f55a322792..5dd41280bf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -9,8 +9,8 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using ManagedCommon; -using Microsoft.CmdPal.Common.Helpers; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CommandPalette.Extensions; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml index 85214dd751..745dc31d95 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml @@ -9,7 +9,6 @@ - diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index c50eab16c0..9ba41a08fb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -3,8 +3,9 @@ // See the LICENSE file in the project root for more information. using ManagedCommon; -using Microsoft.CmdPal.Common.Helpers; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.Ext.Apps; using Microsoft.CmdPal.Ext.Bookmarks; @@ -74,6 +75,11 @@ public partial class App : Application AppWindow?.Close(); Environment.Exit(0); }); + + // Connect the PT logging to the core project's logging. + // This way, log statements from the core project will be captured by the PT logs + var logWrapper = new LogWrapper(); + CoreLogger.InitializeLogger(logWrapper); } /// @@ -108,7 +114,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(files); - services.AddSingleton(); + services.AddSingleton(_ => BookmarksCommandProvider.CreateWithDefaultStore()); services.AddSingleton(); services.AddSingleton(); @@ -154,7 +160,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(new TelemetryForwarder()); + services.AddSingleton(); // ViewModels services.AddSingleton(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml index aba9ed477d..78e234a184 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml @@ -173,9 +173,13 @@ Visibility="{x:Bind ViewModel.HasPrimaryCommand, Mode=OneWay}"> + Text="{x:Bind ViewModel.PrimaryCommand.Name, Mode=OneWay}" + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" /> @@ -192,9 +196,13 @@ Visibility="{x:Bind ViewModel.HasSecondaryCommand, Mode=OneWay}"> + Text="{x:Bind ViewModel.SecondaryCommand.Name, Mode=OneWay}" + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" /> @@ -218,7 +226,9 @@ + Text="More" + TextTrimming="WordEllipsis" + TextWrapping="NoWrap" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml index 5636de9aca..8e44d0f420 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -31,7 +31,7 @@ - + @@ -42,7 +42,7 @@ Height="16" Margin="4,0,0,0" HorizontalAlignment="Left" - SourceKey="{x:Bind Icon}" + SourceKey="{x:Bind Icon, Mode=OneWay}" SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> - + + Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" /> - + @@ -82,7 +83,7 @@ Margin="4,0,0,0" HorizontalAlignment="Left" Foreground="{ThemeResource SystemFillColorCriticalBrush}" - SourceKey="{x:Bind Icon}" + SourceKey="{x:Bind Icon, Mode=OneWay}" SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> - + + Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconMarginConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconMarginConverter.cs new file mode 100644 index 0000000000..a0fb5be87c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconMarginConverter.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.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class IconMarginConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + // Only include a margin if there is text to separate from the icon. + var text = value as string; + return string.IsNullOrEmpty(text) ? new Thickness(0) : new Thickness(0, 0, 4, 0); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index 12f22582fc..5337a126c0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Commands; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.Views; @@ -32,10 +33,22 @@ public sealed partial class SearchBar : UserControl, private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); private bool _isBackspaceHeld; + // Inline text suggestions + // In 0.4-0.5 we would replace the text of the search box with the TextToSuggest + // This was really cool for navigating paths in run and pretty much nowhere else. + // We'll have to try another approach, but for now, the code is still testable. + // You can test this by setting the CMDPAL_ENABLE_SUGGESTION_SELECTION env var to 1 private bool _inSuggestion; + + private bool InSuggestion => _inSuggestion && IsTextToSuggestEnabled; + private string? _lastText; + private string? _deletedSuggestion; + // 0.6+ suggestions + private string? _textToSuggest; + public PageViewModel? CurrentPageViewModel { get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); @@ -109,31 +122,11 @@ public sealed partial class SearchBar : UserControl, return; } - 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); - 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 (ctrlPressed && e.Key == VirtualKey.I) + var ctrlPressed = (InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control) & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down; + if (ctrlPressed && e.Key == VirtualKey.I) { // Today you learned that Ctrl+I in a TextBox will insert a tab + // We don't want that, so we'll suppress it, this way it can be used for other purposes e.Handled = true; } else if (e.Key == VirtualKey.Escape) @@ -194,7 +187,23 @@ public sealed partial class SearchBar : UserControl, } else if (e.Key == VirtualKey.Right) { - if (_inSuggestion) + // Check if the "replace search text with suggestion" feature from 0.4-0.5 is enabled. + // If it isn't, then only use the suggestion when the caret is at the end of the input. + if (!IsTextToSuggestEnabled) + { + if (_textToSuggest != null && + FilterBox.SelectionStart == FilterBox.Text.Length) + { + FilterBox.Text = _textToSuggest; + FilterBox.Select(_textToSuggest.Length, 0); + e.Handled = true; + } + + return; + } + + // Here, we're using the "replace search text with suggestion" feature. + if (InSuggestion) { _inSuggestion = false; _lastText = null; @@ -208,7 +217,7 @@ public sealed partial class SearchBar : UserControl, e.Handled = true; } - if (_inSuggestion) + if (InSuggestion) { if ( e.Key == VirtualKey.Back || @@ -251,22 +260,6 @@ public sealed partial class SearchBar : UserControl, _inSuggestion = false; _lastText = null; } - - if (!e.Handled) - { - 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); - - // 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; - } } private void FilterBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e) @@ -294,7 +287,7 @@ public sealed partial class SearchBar : UserControl, return; } - if (_inSuggestion) + if (InSuggestion) { // Logger.LogInfo($"-- skipping, in suggestion --"); return; @@ -316,7 +309,7 @@ public sealed partial class SearchBar : UserControl, private void DoFilterBoxUpdate() { - if (_inSuggestion) + if (InSuggestion) { // Logger.LogInfo($"--- skipping ---"); return; @@ -368,6 +361,12 @@ public sealed partial class SearchBar : UserControl, public void Receive(UpdateSuggestionMessage message) { + if (!IsTextToSuggestEnabled) + { + _textToSuggest = message.TextToSuggest; + return; + } + var suggestion = message.TextToSuggest; _queue.TryEnqueue(new(() => @@ -457,4 +456,15 @@ public sealed partial class SearchBar : UserControl, } })); } + + private static bool IsTextToSuggestEnabled => _textToSuggestEnabled.Value; + + private static Lazy _textToSuggestEnabled = new(() => QueryTextToSuggestEnabled()); + + private static bool QueryTextToSuggestEnabled() + { + var env = System.Environment.GetEnvironmentVariable("CMDPAL_ENABLE_SUGGESTION_SELECTION"); + return !string.IsNullOrEmpty(env) && + (env == "1" || env.Equals("true", System.StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutDialogContentControl.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutDialogContentControl.xaml index 8ab0fb7586..0000685ac3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutDialogContentControl.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutDialogContentControl.xaml @@ -4,8 +4,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:labToolkit="using:CommunityToolkit.Labs.WinUI.MarkdownTextBlock" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" x:Name="ShortcutContentControl" mc:Ignorable="d"> @@ -66,7 +66,7 @@ IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}" Severity="Warning" /> - @@ -36,7 +36,7 @@ - 4,2,4,2 1 + + + + + + + - + @@ -77,31 +101,22 @@ + - + Visibility="{x:Bind IsLink, Mode=OneWay}"> + + - + + Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), FallbackValue=Collapsed}" /> - + - - + + @@ -170,9 +185,12 @@ + VerticalAlignment="Stretch" + BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderThickness="0,0,0,1"> @@ -349,11 +367,7 @@ - + @@ -362,6 +376,9 @@ @@ -422,12 +439,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" /> - + @@ -512,6 +535,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..6ec7f23a59 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,12 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Dispatching; using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation.Peers; 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; @@ -55,6 +61,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, private readonly ToastWindow _toast = new(); + private readonly CompositeFormat _pageNavigatedAnnouncement; + private SettingsWindow? _settingsWindow; public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService()!; @@ -84,9 +92,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,14 +153,11 @@ 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); - if (!ViewModel.IsNested) { // todo BODGY @@ -247,32 +268,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 +403,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. @@ -421,12 +458,105 @@ 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) + { + element.Loaded += FocusAfterLoaded; + } + } + + private void FocusAfterLoaded(object sender, RoutedEventArgs e) + { + var page = (Page)sender; + page.Loaded -= FocusAfterLoaded; + + 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 () => + { + 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++) + { + if (FocusManager.FindFirstFocusableElement(page) is FrameworkElement frameworkElement) + { + var set = frameworkElement.Focus(FocusState.Programmatic); + if (set) + { + break; + } + } + + await Task.Delay(100); + } + + // 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; + }); + }); + } + } + + 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 +582,60 @@ 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; + if (e.Key == VirtualKey.Left && onlyAlt) { WeakReferenceMessenger.Default.Send(new()); + e.Handled = true; + } + else if (e.Key == VirtualKey.Home && onlyAlt) + { + WeakReferenceMessenger.Default.Send(new(WithAnimation: false)); + e.Handled = true; + } + else + { + // 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; + } + } + + 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; } } 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/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/GeneralPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml index bfff5db1f7..21cee5407c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml @@ -88,6 +88,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..e4acb05ae1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml @@ -24,10 +24,11 @@ - + @@ -36,18 +37,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..76734a5568 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 @@ -407,6 +407,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 @@ -450,4 +456,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut. For Developers + + an untitled + + + Navigated to {0} page + \ 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/ToastWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs index 87e04dfdcf..9f0e63edcc 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; @@ -33,13 +34,18 @@ public sealed partial class ToastWindow : WindowEx, { 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; + // Tool windows don't show up in ALT+TAB, and don't show up in the taskbar + // Since tool windows have smaller corner radii, we need to force the normal ones + // to visually match system toasts. + this.ToggleExtendedWindowStyle(WINDOW_EX_STYLE.WS_EX_TOOLWINDOW, true); + this.SetCornerPreference(DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND); + _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); PInvoke.EnableWindow(_hwnd, false); 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..0751b5afe3 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.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.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); + } +} 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..2fa7b81b08 --- /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..3980ac13c6 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,6 +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. + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; internal sealed class MockBookmarkDataSource : IBookmarkDataSource 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.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/custom.props b/src/modules/cmdpal/custom.props index cf04c2de38..86541e31cc 100644 --- a/src/modules/cmdpal/custom.props +++ b/src/modules/cmdpal/custom.props @@ -5,7 +5,7 @@ true 2025 0 - 5 + 6 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..b5ce5522bb 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; @@ -51,46 +51,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..34f6c9c9c5 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,51 @@ 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 { - private readonly AppItem _app; private static readonly Tag _appTag = new("App"); + private readonly AppCommand _appCommand; + private readonly AppItem _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 +60,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 +111,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 +132,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/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..7485716f1b 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,14 +94,14 @@ 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, }) @@ -348,20 +347,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 +403,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..a45f05cdf3 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,13 @@ 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 OpenFileCommand(ParentDirectory)) { RequestedShortcut = KeyChords.OpenFileLocation, }); @@ -282,7 +281,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 +615,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 +661,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 +711,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 +802,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 +819,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 +956,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 +991,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 +1030,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/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/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..1eb57fb7eb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft 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(); + _bookmarksData = _parser.ParseBookmarks(jsonData); + } + 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/AddBookmarkForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs similarity index 78% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs index 93fc6d8d01..6931064a90 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs @@ -4,38 +4,28 @@ using System.Text.Json; using System.Text.Json.Nodes; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Windows.Foundation; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; internal sealed partial class AddBookmarkForm : FormContent { - internal event TypedEventHandler? AddedCommand; - 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; + 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", @@ -44,6 +34,15 @@ internal sealed partial class AddBookmarkForm : FormContent "label": "{{Resources.bookmarks_form_bookmark_label}}", "isRequired": true, "errorMessage": "{{Resources.bookmarks_form_bookmark_required}}" + }, + { + "type": "Input.Text", + "style": "text", + "id": "name", + "label": "{{Resources.bookmarks_form_name_label}}", + "value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}}, + "isRequired": false, + "errorMessage": "{{Resources.bookmarks_form_name_required}}" } ], "actions": [ @@ -71,13 +70,7 @@ internal sealed partial class AddBookmarkForm : FormContent // 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); + 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..3129e1b578 --- /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 == Guid.Empty ? Guid.NewGuid() : 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..e5a65f2db3 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 @@ -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. /// @@ -177,6 +222,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 +248,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..763c697f2e 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 @@ -164,4 +164,25 @@ {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} + \ 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/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..60e7851761 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft 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; + +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 (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 (IsValidFilePath(url) && !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + 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; + } + + /// + /// Checks if a string represents a valid file path (local or network) + /// + /// The string to check + /// True if the string is a valid file path, false otherwise + private static bool IsValidFilePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + // Check for UNC paths (network paths starting with \\) + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + { + // Basic UNC path validation: \\server\share or \\server\share\path + var parts = path.Substring(2).Split('\\', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2; // At minimum: server and share + } + + // Check for drive letters (C:\ or C:) + if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':') + { + return true; + } + + return false; + } + catch + { + return false; + } + } +} 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..e3d17fb500 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,7 @@ enable - + @@ -39,5 +39,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..9b5aae6f7d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models; + +internal sealed partial class ClipboardListItem : ListItem +{ + private readonly SettingsManager _settingsManager; + private readonly ClipboardItem _item; + + private readonly CommandContextItem _deleteContextMenuItem; + private readonly CommandContextItem? _pasteCommand; + private readonly CommandContextItem? _copyCommand; + private readonly CommandContextItem? _openUrlCommand; + 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)); + + // Check if the text content is a valid URL and add OpenUrl command + if (UrlHelper.IsValidUrl(_item.Content ?? string.Empty)) + { + var normalizedUrl = UrlHelper.NormalizeUrl(_item.Content ?? string.Empty); + _openUrlCommand = new CommandContextItem(new OpenUrlCommand(normalizedUrl)) + { + RequestedShortcut = KeyChords.OpenUrl, + }; + } + else + { + _openUrlCommand = null; + } + } + else + { + _pasteCommand = null; + _copyCommand = null; + _openUrlCommand = 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); + } + + if (_openUrlCommand != null) + { + commands.Add(_openUrlCommand); + } + + commands.Add(new Separator()); + commands.Add(_deleteContextMenuItem); + + return commands.ToArray(); + } + + private Details CreateDetails() + { + IDetailsElement[] metadata = + [ + new DetailsElement + { + Key = "Copied on", + 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..cda1f6ccfc 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 @@ -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. /// @@ -212,5 +221,50 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties { 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..0af6ee4cfc 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 @@ -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/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..3b09fcf149 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,6 +4,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.CmdPal.Ext.Indexer.Properties; using Microsoft.CommandPalette.Extensions; @@ -23,6 +25,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 +36,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable PlaceholderText = Resources.Indexer_PlaceholderText; _searchEngine = new(); _queryCookie = 10; + CreateEmptyContent(); } public IndexerPage(string query, SearchEngine searchEngine, uint queryCookie, IList firstPageData) @@ -43,9 +49,42 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable initialQuery = query; SearchText = query; disposeSearchEngine = false; + CreateEmptyContent(); } - public override ICommandItem EmptyContent => GetEmptyContent(); + private void CreateEmptyContent() + { + _noSearchEmptyContent = new CommandItem(new NoOpCommand()) + { + Icon = Icon, + Title = Resources.Indexer_Subtitle, + 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 encodedSearchText = UrlEncoder.Default.Encode(SearchText); + var command = string.Format(CultureInfo.CurrentCulture, template, encodedSearchText); + ShellHelpers.OpenInShell(command); + } + + public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent : _nothingFoundEmptyContent; public override void UpdateSearchText(string oldSearch, string newSearch) { @@ -74,16 +113,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..44b87b05e2 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. /// @@ -187,7 +205,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 +214,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.... /// 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..66504abed1 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 @@ -184,6 +184,16 @@ 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 + \ No newline at end of file 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..2e4cac7b16 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,20 @@ 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) @@ -92,7 +89,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(); @@ -147,7 +144,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos 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 +153,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 +162,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 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/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/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.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.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.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 @@ - + + + + + + + + + + + \ No newline at end of file diff --git a/src/runner/runner.vcxproj b/src/runner/runner.vcxproj index 98ceedc675..afff599d8e 100644 --- a/src/runner/runner.vcxproj +++ b/src/runner/runner.vcxproj @@ -1,8 +1,7 @@  - - + 81010002 @@ -15,10 +14,20 @@ + + + + + + + + Application v143 + None + true @@ -131,15 +140,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/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index 0e0556d442..977c03b839 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -513,6 +513,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + 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/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..24ff4584fe 100644 --- a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs +++ b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs @@ -76,6 +76,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("enable_experimentation")] public bool EnableExperimentation { get; set; } + [JsonPropertyName("ignored_conflict_properties")] + public ShortcutConflictProperties IgnoredConflictProperties { get; set; } + public GeneralSettings() { Startup = false; @@ -100,6 +103,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 +141,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/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..a58022d4a6 --- /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 = "FixedHours"; + 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..3dac744762 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft 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.Reflection; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Settings.UI.Library +{ + public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable + { + 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 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), + }, + }; + } + + 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/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/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..9325907a72 100644 --- a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs @@ -81,6 +81,8 @@ 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; } 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/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/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/EnumToVisibilityConverter.cs b/src/settings-ui/Settings.UI/Converters/EnumToVisibilityConverter.cs new file mode 100644 index 0000000000..5cb7a5e0ed --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/EnumToVisibilityConverter.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 System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class EnumToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value == null || parameter == null) + { + return Visibility.Collapsed; + } + + string enumString = value.ToString(); + string targetString = parameter.ToString(); + + return enumString.Equals(targetString, StringComparison.OrdinalIgnoreCase) + ? Visibility.Visible + : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} 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..b33ac264ed 100644 --- a/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs @@ -52,6 +52,7 @@ 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.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 +89,7 @@ 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.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; @@ -159,6 +161,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers ModuleType.CmdPal => typeof(CmdPalPage), ModuleType.ColorPicker => typeof(ColorPickerPage), ModuleType.CropAndLock => typeof(CropAndLockPage), + 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 68d0348c7d..dd70af7533 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -22,6 +22,7 @@ + @@ -63,7 +64,7 @@ - + @@ -156,7 +157,9 @@ Always - + + + MSBuild:Compile MSBuild:Compile @@ -170,6 +173,12 @@ MSBuild:Compile + + MSBuild:Compile + + + MSBuild:Compile + @@ -184,7 +193,7 @@ - + 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..b071e7f6fe 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml @@ -8,7 +8,7 @@ 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/KeyVisual/KeyVisual.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml index 931286ceaf..1228911082 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml @@ -63,10 +63,18 @@ - + + + + + + + + + @@ -120,6 +128,11 @@ + + + + + @@ -177,10 +190,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/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..b7983585ac 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"> - + + + 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..31e2f742e6 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/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 @@ - +