diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f9389b8d91..1a85de1e06 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,6 +7,13 @@ body: - type: markdown attributes: value: Please make sure to [search for existing issues](https://github.com/microsoft/PowerToys/issues) before filing a new one! +- type: markdown + attributes: + value: | + We are aware of the following high-volume issues and are actively working on them. Please check if your issue is one of these before filing a new bug report: + * **PowerToys Run crash related to "Desktop composition is disabled"**: This may appear as `COMException: 0x80263001`. For more details, see issue [#31226](https://github.com/microsoft/PowerToys/issues/31226). + * **PowerToys Run crash with `COMException (0xD0000701)`**: For more details, see issue [#30769](https://github.com/microsoft/PowerToys/issues/30769). + * **PowerToys Run crash with a "Cyclic reference" error**: This `System.InvalidOperationException` is detailed in issue [#36451](https://github.com/microsoft/PowerToys/issues/36451). - id: version type: input attributes: diff --git a/.github/actions/spell-check/excludes.txt b/.github/actions/spell-check/excludes.txt index c6f1225788..551c248923 100644 --- a/.github/actions/spell-check/excludes.txt +++ b/.github/actions/spell-check/excludes.txt @@ -105,6 +105,7 @@ ^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$ ^src/common/sysinternals/Eula/ ^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$ +^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$ ^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$ ^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/ ^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$ diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 656db7088a..145d60d23a 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -22,6 +22,7 @@ ADate ADDSTRING ADDUNDORECORD ADifferent +adjacents ADMINS adml admx @@ -34,6 +35,7 @@ AFX AGGREGATABLE AHK AHybrid +AIUI akv ALarger ALIGNRIGHT @@ -63,6 +65,7 @@ APIIs Apm APPBARDATA APPEXECLINK +appext APPLICATIONFRAMEHOST appmanifest APPMODEL @@ -98,7 +101,6 @@ ATX ATRIOX aumid authenticode -Authenticode AUTOBUDDY AUTOCHECKBOX AUTOHIDE @@ -112,6 +114,9 @@ AValid AWAYMODE azcliversion azman +azureaiinference +azureinference +azureopenai bbwe BCIE bck @@ -136,7 +141,7 @@ bla BLACKFRAME BLENDFUNCTION Blockquotes -Blt +blt BLURBEHIND BLURREGION bmi @@ -182,6 +187,7 @@ CAPTUREBLT CAPTURECHANGED CARETBLINKING CAtl +CBN cch CCHDEVICENAME CCHFORMNAME @@ -204,6 +210,7 @@ CIBUILD cidl CIELCh cim +claude CImage cla CLASSDC @@ -236,6 +243,7 @@ CODENAME codereview Codespaces Coen +cognitiveservices COINIT colid colorconv @@ -250,6 +258,7 @@ cominterop commandnotfound commandpalette compmgmt +COMPOSITIONDISABLED COMPOSITIONFULL CONFIGW CONFLICTINGMODIFIERKEY @@ -306,6 +315,7 @@ CURRENTDIR CURSORINFO cursorpos CURSORSHOWING +CURSORWRAP customaction CUSTOMACTIONTEST CUSTOMFORMATPLACEHOLDER @@ -405,6 +415,9 @@ DNLEN DONOTROUND DONTVALIDATEPATH dotnet +downsampled +downsampling +Downsampled downscale DPICHANGED DPIs @@ -420,7 +433,7 @@ DROPFILES DSTINVERT DString DSVG -DTo +dto DUMMYUNIONNAME dutil DVASPECT @@ -454,7 +467,6 @@ EDITKEYBOARD EDITSHORTCUTS EDITTEXT EFile -ekus eku emojis ENABLEDELAYEDEXPANSION @@ -551,6 +563,7 @@ FIXEDSYS flac flyouts FMask +foundrylocal fmtid FOF FOFX @@ -589,6 +602,7 @@ getfilesiginforedist geolocator GETHOTKEY GETICON +GETLBTEXT GETMINMAXINFO GETNONCLIENTMETRICS GETPROPERTYSTOREFLAGS @@ -596,6 +610,7 @@ GETSCREENSAVERRUNNING GETSECKEY GETSTICKYKEYS GETTEXTLENGTH +GIFs gitmodules GHND GMEM @@ -606,6 +621,7 @@ GPOCA gpp gpu gradians +grctlext Gridcustomlayout GSM gtm @@ -615,6 +631,8 @@ GValue gwl GWLP GWLSTYLE +googleai +googlegemini hangeul Hanzi Hardlines @@ -1136,10 +1154,10 @@ NONCLIENTMETRICSW NONELEVATED nonspace nonstd -nullrefs NOOWNERZORDER NOPARENTNOTIFY NOPREFIX +NPU NOREDIRECTIONBITMAP NOREDRAW NOREMOVE @@ -1175,8 +1193,8 @@ ntfs NTSTATUS NTSYSAPI NULLCURSOR -nullref nullonfailure +nullref numberbox nwc ocr @@ -1199,6 +1217,9 @@ opencode OPENFILENAME opensource openxmlformats +ollama +Olllama +onnx OPTIMIZEFORINVOKE ORPHANEDDIALOGTITLE ORSCANS @@ -1388,6 +1409,8 @@ pwsz pwtd QDC qit +QNN +Qualcomm QITAB QITABENT qoi @@ -1851,6 +1874,7 @@ UPDATENOW UPDATEREGISTRY updown UPGRADINGPRODUCTCODE +upscaling Uptool urld Usb @@ -1937,6 +1961,7 @@ wcsicmp wcsncpy wcsnicmp WCT +WCRAPI WDA wdm wdp @@ -1980,6 +2005,8 @@ WINL winlogon winmd WINNT +windowsml +winml winres winrt winsdk diff --git a/.github/actions/spell-check/patterns.txt b/.github/actions/spell-check/patterns.txt index cb303a10ad..181d728e84 100644 --- a/.github/actions/spell-check/patterns.txt +++ b/.github/actions/spell-check/patterns.txt @@ -253,7 +253,7 @@ _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING # hit-count: 1 file-count: 1 # Amazon -\bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|)[^"'\s]+ +\bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|) # hit-count: 3 file-count: 3 # imgur diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 04f9cfaaeb..560b44b5a4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,6 +5,7 @@ ## PR Checklist - [ ] Closes: #xxx + - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized diff --git a/.github/workflows/msstore-submissions.yml b/.github/workflows/msstore-submissions.yml index a44dafb199..36dfc4d785 100644 --- a/.github/workflows/msstore-submissions.yml +++ b/.github/workflows/msstore-submissions.yml @@ -40,7 +40,7 @@ jobs: echo powerToysInstallerArm64Url=$(jq -n "$powerToysSetup" | jq -r '[.[]|select(.name | contains("arm64"))][0].browser_download_url') >> $GITHUB_OUTPUT - name: Setup .NET 9.0 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '9.0.x' diff --git a/.gitignore b/.gitignore index ed3f80a4ec..1318abc22c 100644 --- a/.gitignore +++ b/.gitignore @@ -349,10 +349,7 @@ src/common/Telemetry/*.etl /src/modules/powerrename/ui/RCb24464 # 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/.gitmodules b/.gitmodules index 1601291341..f878c1a9e3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,3 @@ [submodule "deps/expected-lite"] path = deps/expected-lite url = https://github.com/martinmoene/expected-lite.git -[submodule "deps/cziplib"] - path = deps/cziplib - url = https://github.com/kuba--/zip.git diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index d99fabf0eb..83289fa102 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -5,7 +5,6 @@ { "MatchedPath": [ "*.resources.dll", - "WinUI3Apps\\Assets\\Settings\\Scripts\\*.ps1", "PowerToys.ActionRunner.exe", @@ -27,6 +26,7 @@ "PowerToys.GPOWrapper.dll", "PowerToys.GPOWrapperProjection.dll", "PowerToys.AllExperiments.dll", + "LanguageModelProvider.dll", "Common.Search.dll", @@ -181,6 +181,7 @@ "PowerToys.MousePointerCrosshairs.dll", "PowerToys.MouseJumpUI.dll", "PowerToys.MouseJumpUI.exe", + "PowerToys.CursorWrap.dll", "PowerToys.MouseWithoutBorders.dll", "PowerToys.MouseWithoutBorders.exe", @@ -290,6 +291,7 @@ "Mono.Cecil.Rocks.dll", "Newtonsoft.Json.dll", "CommunityToolkit.WinUI.Controls.TitleBar.dll", + "CommunityToolkit.WinUI.Controls.OpacityMaskView.dll", "NLog.dll", "HtmlAgilityPack.dll", @@ -346,6 +348,8 @@ "Testably.Abstractions.FileSystem.Interface.dll", "WinUI3Apps\\Testably.Abstractions.FileSystem.Interface.dll", "ColorCode.Core.dll", + "Microsoft.SemanticKernel.Connectors.Ollama.dll", + "OllamaSharp.dll", "UnitsNet.dll", "UtfUnknown.dll", diff --git a/.pipelines/generateDscManifests.ps1 b/.pipelines/generateDscManifests.ps1 index 109610e62e..e0a2f463af 100644 --- a/.pipelines/generateDscManifests.ps1 +++ b/.pipelines/generateDscManifests.ps1 @@ -65,21 +65,28 @@ if (-not (Test-Path $outputDir)) { New-Item -Path $outputDir -ItemType Directory -Force | Out-Null } -Write-Host "DSC manifests will be generated to: '$outputDir'" +# DSC v3 manifests go to DSCModules subfolder +$dscOutputDir = Join-Path $outputDir 'DSCModules' +if (-not (Test-Path $dscOutputDir)) { + Write-Host "Creating DSCModules subfolder at '$dscOutputDir'." + New-Item -Path $dscOutputDir -ItemType Directory -Force | Out-Null +} -Write-Host "Cleaning previously generated DSC manifest files from '$outputDir'." -Get-ChildItem -Path $outputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction SilentlyContinue | Remove-Item -Force +Write-Host "DSC manifests will be generated to: '$dscOutputDir'" -$arguments = @('manifest', '--resource', 'settings', '--outputDir', $outputDir) +Write-Host "Cleaning previously generated DSC manifest files from '$dscOutputDir'." +Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction SilentlyContinue | Remove-Item -Force + +$arguments = @('manifest', '--resource', 'settings', '--outputDir', $dscOutputDir) Write-Host "Invoking DSC manifest generator: '$exePath' $($arguments -join ' ')" & $exePath @arguments if ($LASTEXITCODE -ne 0) { throw "PowerToys.DSC.exe exited with code $LASTEXITCODE" } -$generatedFiles = Get-ChildItem -Path $outputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction Stop +$generatedFiles = Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction Stop if ($generatedFiles.Count -eq 0) { - throw "No DSC manifest files were generated in '$outputDir'." + throw "No DSC manifest files were generated in '$dscOutputDir'." } Write-Host "Generated $($generatedFiles.Count) DSC manifest file(s):" diff --git a/.pipelines/v2/ci.yml b/.pipelines/v2/ci.yml index 6b0105a38a..297c268757 100644 --- a/.pipelines/v2/ci.yml +++ b/.pipelines/v2/ci.yml @@ -32,7 +32,7 @@ parameters: - name: enableMsBuildCaching type: boolean displayName: "Enable MSBuild Caching" - default: true + default: false - name: runTests type: boolean displayName: "Run Tests" diff --git a/.pipelines/v2/release.yml b/.pipelines/v2/release.yml index e8cc7d5ed8..71f80f574b 100644 --- a/.pipelines/v2/release.yml +++ b/.pipelines/v2/release.yml @@ -52,8 +52,6 @@ extends: name: SHINE-INT-S ${{ if eq(parameters.useVSPreview, true) }}: demands: ImageOverride -equals SHINE-VS17-Preview - ${{ else }}: - image: SHINE-VS17-Latest os: windows sdl: tsa: @@ -75,7 +73,6 @@ extends: name: SHINE-INT-L demands: # Our INT agents have a large disk mounted at P:\ - - WorkFolder -equals P:\_work - ${{ if eq(parameters.useVSPreview, true) }}: - ImageOverride -equals SHINE-VS17-Preview os: windows @@ -126,7 +123,6 @@ extends: parameters: pool: name: SHINE-INT-L - image: SHINE-VS17-Latest os: windows official: true codeSign: true diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index 5c2f74e9e0..62cb993d5d 100644 --- a/.pipelines/v2/templates/job-build-project.yml +++ b/.pipelines/v2/templates/job-build-project.yml @@ -111,6 +111,7 @@ jobs: ${{ else }}: OutputBuildPlatform: ${{ platform }} variables: + NUGET_PACKAGES: 'C:\NuGetPackages' # Some of our build steps cache these here... and it was apparently part of the global environment MakeAppxPath: 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x86\MakeAppx.exe' # Azure DevOps abhors a vacuum # If these are blank, expansion will fail later on... which will result in direct substitution of the variable *names* @@ -139,6 +140,10 @@ jobs: - output: pipelineArtifact artifactName: $(JobOutputArtifactName) targetPath: $(Build.ArtifactStagingDirectory) + - output: pipelineArtifact + artifactName: $(JobOutputArtifactName)-failure-$(System.JobAttempt) + targetPath: $(LogOutputDirectory) + condition: or(failed(), canceled()) steps: - checkout: self clean: true @@ -266,6 +271,26 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + - task: VSBuild@1 + displayName: Generate DSC artifacts for ARM64 + condition: and(succeeded(), eq(variables['BuildPlatform'], 'arm64')) + inputs: + solution: PowerToys.sln + vsVersion: 17.0 + msbuildArgs: >- + -restore + /p:Configuration=$(BuildConfiguration) + /p:Platform=x64 + /t:DSC\PowerToys_Settings_DSC_Schema_Generator + /bl:$(LogOutputDirectory)\build-dsc-generator.binlog + ${{ parameters.additionalBuildOptions }} + $(MSBuildCacheParameters) + $(RestoreAdditionalProjectSourcesArg) + platform: x64 + configuration: $(BuildConfiguration) + msbuildArchitecture: x64 + maximumCpuCount: true + # Build PowerToys.DSC.exe for ARM64 (x64 uses existing binary from previous build) - task: VSBuild@1 displayName: Build PowerToys.DSC.exe (x64 for generating manifests) @@ -375,7 +400,7 @@ jobs: ### HACK: On ARM64 builds, building an app with Windows App SDK copies the x64 WebView2 dll instead of the ARM64 one. This task makes sure the right dll is used. - task: CopyFiles@2 displayName: HACK Copy core WebView2 ARM64 dll to output directory - condition: eq(variables['BuildPlatform'],'arm64') + condition: and(succeeded(), eq(variables['BuildPlatform'], 'arm64')) inputs: contents: packages/Microsoft.Web.WebView2.1.0.2903.40/runtimes/win-ARM64/native_uap/Microsoft.Web.WebView2.Core.dll targetFolder: $(Build.SourcesDirectory)/ARM64/Release/WinUI3Apps/ @@ -414,11 +439,11 @@ jobs: inputs: testResultsFormat: VSTest testResultsFiles: '**/*.trx' - condition: ne(variables['BuildPlatform'],'arm64') + condition: and(succeeded(), ne(variables['BuildPlatform'], 'arm64')) # Native dlls - task: VSTest@2 - condition: ne(variables['BuildPlatform'],'arm64') # No arm64 agents to run the tests. + condition: and(succeeded(), ne(variables['BuildPlatform'], 'arm64')) # No arm64 agents to run the tests. displayName: 'Native Tests' inputs: platform: '$(BuildPlatform)' diff --git a/.vscode/launch.json b/.vscode/launch.json index b2d2bca9ac..940ff302de 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,6 +38,17 @@ "env": {}, "console": "internalConsole", "stopAtEntry": false - } + }, + { + "name": "Run AdvancedPaste (managed, no build, ARCH configurable)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\WinUI3Apps\\PowerToys.AdvancedPaste.exe", + "args": [], + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole", + "stopAtEntry": false + }, ] } \ No newline at end of file diff --git a/Cpp.Build.props b/Cpp.Build.props index a616361522..f146a4d770 100644 --- a/Cpp.Build.props +++ b/Cpp.Build.props @@ -1,56 +1,6 @@ - - - $(Configuration) - - - - - - - - - - - MultiThreadedDebug - - - - %(IgnoreSpecificDefaultLibraries);libucrtd.lib - %(AdditionalOptions) /defaultlib:ucrtd.lib - - - - - - MultiThreaded - - - - %(IgnoreSpecificDefaultLibraries);libucrt.lib - %(AdditionalOptions) /defaultlib:ucrt.lib - - - - - - - MultiThreadedDebugDLL - - - - - MultiThreadedDLL - - @@ -76,6 +26,7 @@ true $(MsbuildThisFileDirectory)\CppRuleSet.ruleset + $(MSBuildThisFileDirectory)deps;$(MSBuildThisFileDirectory)packages;$(CAExcludePath) @@ -84,7 +35,7 @@ arm64 false true - $(MSBuildThisFileFullPath)\..\deps\;$(MSBuildThisFileFullPath)\..\packages\;$(ExternalIncludePath) + $(MSBuildThisFileDirectory)deps;$(MSBuildThisFileDirectory)packages;$(ExternalIncludePath) Guard @@ -123,6 +74,7 @@ _DEBUG;%(PreprocessorDefinitions) Disabled + MultiThreadedDebug true @@ -132,6 +84,7 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed + MultiThreaded true true diff --git a/DATA_AND_PRIVACY.md b/DATA_AND_PRIVACY.md index 56a2eb9eee..0ad4bda9c9 100644 --- a/DATA_AND_PRIVACY.md +++ b/DATA_AND_PRIVACY.md @@ -147,6 +147,18 @@ _If you want to find diagnostic data events in the source code, these two links Microsoft.PowerToys.AdvancedPasteSemanticKernelFormatEvent Triggered when Advanced Paste leverages the Semantic Kernel. + + Microsoft.PowerToys.AdvancedPasteSemanticKernelErrorEvent + Occurs when the Semantic Kernel workflow encounters an error. + + + Microsoft.PowerToys.AdvancedPasteEndpointUsageEvent + Logs the AI provider, model, and processing duration for each endpoint call. + + + Microsoft.PowerToys.AdvancedPasteCustomActionErrorEvent + Records provider, model, and status details when a custom action fails. + ### Always on Top diff --git a/Directory.Packages.props b/Directory.Packages.props index 129abadb00..75b2399c8a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,4 @@ - + true true @@ -7,9 +7,9 @@ + - @@ -35,23 +35,33 @@ - + - + - - - - - - + + + + + + + + + + + + + + + + - + - + - + - - - + + + - + - - + + + - + - - - - + + + + @@ -127,4 +138,4 @@ - + \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md index 1998ea805a..6ca3cbfceb 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1495,10 +1495,10 @@ SOFTWARE. - AdaptiveCards.Rendering.WinUI3 - AdaptiveCards.Templating - Appium.WebDriver -- Azure.AI.OpenAI - CoenM.ImageSharp.ImageHash - CommunityToolkit.Common - CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock +- CommunityToolkit.Labs.WinUI.Controls.OpacityMaskView - CommunityToolkit.Mvvm - CommunityToolkit.WinUI.Animations - CommunityToolkit.WinUI.Collections diff --git a/PowerToys.sln b/PowerToys.sln index 5ad2dbd546..da528ebb06 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -788,12 +788,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSea EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CursorWrap", "src\modules\MouseUtils\CursorWrap\CursorWrap.vcxproj", "{48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3DCCD936-D085-4869-A1DE-CA6A64152C94}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\modules\LightSwitch\Tests\LightSwitch.UITests\LightSwitch.UITests.csproj", "{F5333ED7-06D8-4AB3-953A-36D63F08CB6F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", "{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanguageModelProvider", "src\common\LanguageModelProvider\LanguageModelProvider.csproj", "{45354F4F-1414-45CE-B600-51CD1209FD19}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.UI.ViewModels.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.UI.ViewModels.UnitTests\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj", "{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}" EndProject Global @@ -2962,6 +2966,14 @@ Global {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|ARM64.Build.0 = Debug|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|x64.ActiveCfg = Debug|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|x64.Build.0 = Debug|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|ARM64.ActiveCfg = Release|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|ARM64.Build.0 = Release|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|x64.ActiveCfg = Release|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|x64.Build.0 = Release|x64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.ActiveCfg = Debug|ARM64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Build.0 = Debug|ARM64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Deploy.0 = Debug|ARM64 @@ -2982,6 +2994,14 @@ Global {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.Build.0 = Release|ARM64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.Build.0 = Release|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|ARM64.Build.0 = Debug|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|x64.ActiveCfg = Debug|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|x64.Build.0 = Debug|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|ARM64.ActiveCfg = Release|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|ARM64.Build.0 = Release|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|x64.ActiveCfg = Release|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|x64.Build.0 = Release|x64 {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.ActiveCfg = Debug|ARM64 {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.Build.0 = Debug|ARM64 {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.ActiveCfg = Debug|x64 @@ -3315,9 +3335,11 @@ Global {E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5} = {322566EF-20DC-43A6-B9F8-616AF942579A} {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {45354F4F-1414-45CE-B600-51CD1209FD19} = {1AFB6476-670D-4E80-A464-657E01DFF482} {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/README.md b/README.md index e737281523..624d95501b 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,23 @@

Microsoft PowerToys

- +

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

Installation - . + · Documentation - . + · Blog - . + · Release notes



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

+ +## 🔨 Utilities + +PowerToys includes over 25 utilities to help you customize and optimize your Windows experience: | | | | |---|---|---| @@ -37,41 +41,35 @@ Microsoft PowerToys is a collection of utilities that help you customize Windows ## 📋 Installation -For detailed installation instructions, visit the [installation docs](https://learn.microsoft.com/windows/powertoys/install). - -Before you begin, make sure your device meets the system 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 - -Choose one of the installation methods below: +For detailed installation instructions and system requirements, visit the [installation docs](https://learn.microsoft.com/windows/powertoys/install). +But to get started quickly, choose one of the installation methods below: +

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

@@ -82,10 +80,9 @@ You can easily install PowerToys from the Microsoft Store:

-
-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]* @@ -100,162 +97,137 @@ winget install --scope machine Microsoft.PowerToys -s winget
-Other 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.
## ✨ What's new -**Version 0.95 (October 2025)** +**Version 0.96 (November 2025)** For an in-depth look at the latest changes, visit the [Windows Command Line blog](https://aka.ms/powertoys-releaseblog). **✨ Highlights** - - **NEW:** The **Light Switch** utility in PowerToys allows you to automatically switch between light and dark themes in Windows based on the time of day. - - Command Palette delivered major search performance gains (new fuzzy matcher and smarter fallbacks) improving relevance and speed. - - Peek can now be activated using just the Spacebar! - - Find My Mouse added transparent spotlight with independent backdrop opacity, boosting focus and accessibility. - - Settings now lets you delete shortcuts entirely and ignore conflicts. - - Mouse Pointer Crosshairs gained orientation options (vertical / horizontal / both) for customizable accessibility. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! - - PowerRename fixed enumeration counter skipping ensuring reliable batch renames. Thanks [@daverayment](https://github.com/daverayment)! - - ZoomIt restored legacy draw and snipping behaviors, and fixed recording issues, improving reliability. Thanks [@chakrik73](https://github.com/chakrik73)! + - Advanced Paste now supports multiple online and on-device AI model providers: Azure OpenAI, OpenAI, Google Gemini, Mistral, Foundry Local and Ollama. + - Command Palette received extensive improvements including file search filters, better clipboard history metadata, context-menu styling, and dozens of bug fixes and enhancements. + - PowerRename can now extract and use photo metadata (EXIF, XMP) in renaming patterns like `%Camera`, `%Lens`, and `%ExposureTime`. + +### Advanced Paste + - Advanced Paste now lets you connect to multiple AI providers instead of being limited to a single OpenAI provider. See [Advanced Paste documentation](https://learn.microsoft.com/windows/powertoys/advanced-paste) for usage. + +### Awake + - The Awake countdown timer now stays accurate over long periods. Thanks [@daverayment](https://github.com/daverayment)! + - Fixed Awake context menu positioning. The fix removed the conversion of the mouse cursor from screen to client-window coordinates, instead using the raw screen coordinates returned by GetCursorPos; the context menu now appears at the correct screen position. Thanks [@lzandman](https://github.com/lzandman)! ### Command Palette - - Applied conditional margin for icon-only tags to tighten layout. Thanks [@samrueby](https://github.com/samrueby) - - Improved the reliability of accessing Command Palette settings through PowerToys Settings and executing other x-cmdpal:// protocol commands. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Enabled AOT by default for improved performance while simplifying publish configs. - - Replaced service state color dots with play/pause/stop icons for enhanced accessibility. Thanks [@samrueby](https://github.com/samrueby) - - Fixed filter dropdown sync and crash by binding SelectedValue and raising UI-thread notifications. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Ensured long links wrap correctly in details view. - - Removed animation and enforced minimum width on filter dropdown for clarity. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Restored focus to More button after ESC closes context menu, improving keyboard flow. Thanks [@chatasweetie](https://github.com/chatasweetie) - - Marked main and toast windows as tool windows to keep them out of Alt+Tab while preserving style. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Fixed AOT template and theming issues for filter separators. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Introduced grid layouts (small, medium, gallery) for richer page presentation. - - Materialized result lists to avoid rescoring overhead. - - Disabled problematic selection TextToSuggest behind environment flag. - - Major search performance improvements (new fuzzy matcher, smarter fallbacks, fewer exceptions). - - Added context menu "Show Details" command when details pane is hidden. - - Reduced window flicker by avoiding unnecessary cloaking. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Restored EmptyContent rendering for blank states. Thanks [@DevLGuilherme](https://github.com/DevLGuilherme) - - Saved new state even if prior app state file was corrupt (better resilience). Thanks [@jiripolasek](https://github.com/jiripolasek) - - Migrated settings window to WinUI TitleBar control. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Prevented crash on duplicate keybindings and simplified matching. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Hotkeys now always respect the “Ignore shortcut in fullscreen” setting. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Hid search box on content pages, improving focus and accessibility, and added Home title. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Blocked Ctrl+I from inserting stray tabs in search box. - - Logged HRESULT codes in error logs for deeper diagnostics. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Advanced font and emoji icon classification and alignment improvements. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Ensured that fallback command icons are visible on the extension settings page. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Fixed breadcrumb margin misalignment (visual polish). Thanks [@jiripolasek](https://github.com/jiripolasek) - - Truncated overly long command labels with ellipsis to prevent overflow. - - Added a setting to configure the page transition animation. - - Collection of small improvements and nits for Run Commands. - - Improved bookmarks performance and experience. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Added Ctrl+O shortcut in Clipboard History to open links directly. - - Resolved conflict with external software that blocked Command Palette from hiding. - - Updated context menu items to reflect name and icon changes, and ensured application icons are displayed correctly. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Added Alt+Home shortcut to return immediately to the Command Palette home page. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Fixed a crash when displaying code blocks in markdown on detail or content pages. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Fixed an issue where the search bar icon and title were not updated when rapidly switching pages. Thanks [@jiripolasek](https://github.com/jiripolasek) - - Improved the appearance of the search box in the context menu. - + - The search field in context menus now matches the look of the Command Palette, with a smoke backdrop and improved padding. + - Fallback items such as math calculations or the Run command now appear in results more quickly. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Ensured the command bar updates correctly after navigating to another page and commands are displayed correctly. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - The Command Palette settings page has been reorganized. Activation-key options are grouped under an expander and extension settings are framed for improved readability. + - When you modify a command, its alias, hotkey, and tags now update in the top-level list, keeping the displayed information in sync. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Press `Ctrl + ,` to open Command Palette settings from anywhere. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - You can use `Page Up` and `Page Down` to navigate the list while focus is in the search box. Thanks [@samrueby](https://github.com/samrueby)! + - Fixed an issue where the search box could disappear when navigating pages. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Ensured search text is selected when *Go home when activated* and *Highlight search on activate* are both enabled. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Fixed an issue where Command Palette window occasionally appeared on the taskbar under certain Windows settings. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Ensured that labels and icons of list items and menu items update when they change. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Fixed visibility of list filters when navigating to a content page. Thanks [@DevLGuilherme](https://github.com/DevLGuilherme)! + - Added search to the extension list and a link to extensions on the Microsoft Store. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Added options to open the Command Palette window at its last position or re-center it. + - The Command Palette now remembers its window size after restarting. + - Added a global error handler that logs fatal errors and provides feedback when unexpected failures force Command Palette to close. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Fixed forms and extension settings not showing on some machines due to a missing VC++ runtime. + - Restored ranking of fallback commands for built-in extensions (Sleep, Shutdown, Windows settings, Web search, etc.). Thanks [@jiripolasek](https://github.com/jiripolasek). + - Improved and unified labels and texts across the application! + - Maintainance: Resolved numerous build warnings in Command Palette projects; no user-visible impact. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Maintainance: Fixed a logging issue so exception messages are properly recorded instead of placeholder text, improving troubleshooting. Thanks [@jiripolasek](https://github.com/jiripolasek)! ### Command Palette Extensions - - Replaced localized WebSearch setting keys with stable literals and numeric history count. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Enabled advanced markdown tables and emphasis extensions. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Added setting to choose Clipboard History primary action (Paste vs Copy). Thanks [@jiripolasek](https://github.com/jiripolasek) - - Added actionable empty-state hints for File Search (search PC / open indexing settings). Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Ensured all WinGet extension assets copy reliably to output. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Improved Run command line parsing for paths with spaces; sped up related tests. - - Updated WebSearch extension icon set for enhanced clarity and contrast. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Added Terminal profile sort order setting including MRU tracking. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Added Uninstall Application command (UWP direct, Win32 via Settings). Thanks [@mKpwnz](https://github.com/mKpwnz)! - - Deferred WinGet details loading and added timing logs. - - Removed LINQ from All Apps extension for performance. - - Added standardized key chord system + shortcuts to File Search commands. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Added Terminal channel filter & remembered selection option. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Enabled loading local/data/app images in markdown with sizing hints. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Added external extension reload via x-cmdpal://reload (configurable). Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Instant WebSearch history updates with in-memory store & events. Thanks [@jiripolasek](https://github.com/jiripolasek)! - - Added keep-after-paste option and safe delete with confirmation for Clipboard History. Thanks [@jiripolasek](https://github.com/jiripolasek)! - -### Environment Variables - - Replaced custom window chrome with WinUI TitleBar for cleaner, maintainable Environment Variables UI. - -### File Locksmith - - Adopted WinUI TitleBar to simplify window chrome while preserving appearance. + - Bookmarks: Added hints about bookmark placeholders to the Add/Edit Bookmark form. — Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Bookmarks: Improved migration of bookmarks from older versions and fixed an issue where aliases or keyboard shortcuts could be lost after restart. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Clipboard history: Items shown in Command Palette’s clipboard history now include helpful metadata. For example, image items show dimensions, text files show names and sizes, web links include page titles, and text entries display word counts. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - File search: Added filter buttons to show *all items*, *files only*, or *folders only*. Selecting a filter adds `kind:folders` or `kind:not folders` to narrow results. + - System commands: Replaced the `:red_circle:` placeholder with an actual red-circle emoji so the correct icon appears in the UI. Thanks [@samrueby](https://github.com/samrueby)! + - WinGet: Search performance feels more responsive because typed input is now processed via a task queue rather than complex cancellation tokens! + - Window Walker: UWP apps no longer show a "not responding" label when suspended. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Window Walker: Now displays the actual icon of each window rather than using the process icon, improving recognition of PWAs and Python GUIs. Thanks [@Lee-WonJun](https://github.com/Lee-WonJun)! +- Windows Terminal profiles: Fixed a rare crash in the Windows Terminal extension when the `LOCALAPPDATA` environment variable was missing. The path is now retrieved via a reliable API. Thanks [@jiripolasek](https://github.com/jiripolasek)! ### Find My Mouse - - Added transparent spotlight support with separate backdrop opacity; migrated to Windows App SDK composition APIs. + - Activating Find My Mouse no longer makes the cursor change to the busy (hourglass) icon or steals focus from your active application. ### Hosts File Editor - - Migrated to native WinUI TitleBar for cleaner, maintainable window chrome. + - Added customizable backup settings allowing users to configure backup frequency, location, and auto-deletion policies. Thanks [@davidegiacometti](https://github.com/davidegiacometti)! + +### Image Resizer + - Fixed settings consistency during batch resize operations by capturing settings once before processing. Thanks [@daverayment](https://github.com/daverayment)! ### Light Switch - - Introduced as a brand-new PowerToy module. - - Automatically switches between light and dark themes. - - Supports time-based scheduling or location-based sunrise/sunset switching. - - Supports using a keyboard shortcut to force a change. - - Supports filtering changes for Apps and/or System Theme. +- Introduced new UI to allow users to manually enter their latitude and longitude in Sunrise to Sunset mode. +- Refactored service with cleaner state management for stability. +- Removed logs from every tick, only logging key events to largely reduce log size. ### Mouse Pointer Crosshairs - - Added Esc key to cancel active gliding cursor sequence. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! - - Added orientation option (vertical / horizontal / both) for crosshairs customization. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! + - Enabled switching between Mouse Pointer Crosshairs and Gliding Cursor modes. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! ### Mouse Without Borders - - Continued Common class refactor (part 5/7) by extracting clipboard and init/cleanup logic into focused classes. Thanks [@mikeclayton](https://github.com/mikeclayton)! - - - Fix connection failures caused by conflicting MachineId across machines. Thanks [@noraa-junker](https://github.com/noraa-junker) for troubleshooting! + - Added horizontal scrolling support. Thanks [@MasonBergstrom](https://github.com/MasonBergstrom)! ### Peek - - Added the option to activate Peek with just the Spacebar. +- Fixed media files remaining locked after preview window closes. Thanks [@daverayment](https://github.com/daverayment)! +- Added a command-line interface for file previewing. See the [Peek documentation](https://learn.microsoft.com/windows/powertoys/peek) for usage. Thanks [@prochan2](https://github.com/prochan2)! ### PowerRename - - Fixed enumeration counter skipping when regex replacement equals original filename (counters now advance reliably). Thanks [@daverayment](https://github.com/daverayment)! +- PowerRename no longer crashes due to a missing resources file. +- Added photo metadata extraction support using EXIF and XMP for pattern-based renaming with camera info, GPS coordinates, and date taken. See [PowerRename Documentation](https://learn.microsoft.com/en-us/windows/powertoys/powerrename). -### Quick Accent - - Expanded Welsh layout with acute, grave, and dieresis variants for vowels (consistent ordering). Thanks [@PesBandi](https://github.com/PesBandi)! +### PowerToys Run + - Added retry logic with exponential backoff to handle DWM composition errors during theme changes. Thanks [@jiripolasek](https://github.com/jiripolasek)! + - Updated OneNote icons to reflect new Microsoft 365 design. Thanks [@trevorNgo](https://github.com/trevorNgo)! -### Registry Preview - - Migrated to native TitleBar and AppWindow APIs for cleaner window chrome. + ### Quick Accent + - Added diameter symbol (⌀) for Shift+O in Special Characters mode, thanks to [@anselumjuju](https://github.com/anselumjuju)! -### Screen Ruler - - Fixed ARM64 crash by aligning cursor position structure to 8-byte boundary. +### Zoomit + - Smoothed out zoom-animation in ZoomIt by coalescing mouse-move and timer events, thanks to [@foxmsft](https://github.com/foxmsft)! + - Enabled GIF support for ZoomIt, thanks to [@MarioHewardt](https://github.com/MarioHewardt)! + - Fixed spelling mistakes, and refactored some literal strings to string constants, thanks to [@lzandman](https://github.com/lzandman)! + - Fixed inaccurate "actual size" screenshots in ZoomIt and resolves a GDI handle leak, improving capture fidelity and long-session stability. thanks to [@daverayment](https://github.com/daverayment)! ### Settings - - Added ability to ignore specific hotkey conflicts to reduce noise. - - Stopped creating backup directory during dry-run status checks (cleaner first-run). - - Standardized casing and localization for ZoomIt and modules header. - - Improved search results page accessibility and conditional module grouping. +- Fixed title bar overlapping issue at smaller window sizes. +- Refined shortcut control visual design with improved consistency and spacing. +- Added dashboard utilities sorting by name or status. +- Made update notification InfoBar in flyout clickable for direct navigation to update page. +- Expanded installation instructions by default in README. +- Improved accessibility for shortcut conflict button with static resource-based automation properties. +- Added ScrollViewer to Command Palette page in PowerToys Settings. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Fixed module list glitches and Sort Status checkmark issue. Thanks [@daverayment](https://github.com/daverayment)! -### ZoomIt - - Updated resource file to reflect standalone v9.01 and current copyright year. Thanks [@foxmsft](https://github.com/foxmsft)! - - Restored legacy draw/snipping behaviors and fixed recording race conditions. Thanks [@chakrik73](https://github.com/chakrik73)! - - Added smooth image option for improved zoom quality using GDI+ for static zoom and Magnifier API for live zoom. Thanks [@markrussinovich](https://github.com/markrussinovich)! - - ### Documentation - - New Microsoft Learn documentation for the Light Switch module. - - New dev docs for the Light Switch module. - -### Development (Area-Build & Area-Tests) -- Allowed debug launches to continue when modules fail to load, speeding developer iteration. -- Fixed spell checker dictionary entry (advapi) to eliminate false error. -- Added VS Code development guide and launch configs to streamline cross-editor workflows. -- Upgraded Windows App SDK and related dependencies to 1.8 for newer platform features. -- Rewrote YAML comment to resolve new spell checker forbidden pattern. Thanks [@jiripolasek](https://github.com/jiripolasek)! -- Corrected solution structure by returning misplaced Common project, reducing build confusion. -- Modernized build scripts with shared helpers and VS environment autodetection for simpler CLI builds. -- Standardized build scripts and platform detection to improve reliability and reuse. -- Added missing Command Palette version bump to align module release cadence. -- Added EXECUTEDEFAULT term to dictionary to prevent regression build failures. Thanks [@jiripolasek](https://github.com/jiripolasek)! -- Introduced nightly pre-warm pipeline and configurable MSBuild cache mode to improve CI performance. -- Resolved CI forbidden pattern spelling complaint to keep pipelines green. -- Added AI contributor instruction set to clarify code area expectations. -- Added accessibility IDs to settings and FancyZones toggles, stabilizing UI tests. -- Added automatic log collection on UI test failures to speed root cause analysis. -- Stabilized Mouse Utils tests by switching to AccessibilityId selectors. -- Added Screen Ruler UI test coverage to validate core measurement workflows. +### Development +- Fixed accessibility by associating controls with labels for screen readers. +- Added accessible name to Shortcut Conflicts button for screen readers. +- Excluded TitleBars from tab navigation across multiple utilities. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Migrated build infrastructure from Windows Server 2019 to Server 2022 with improved failure logging and predictable NuGet package paths. +- Configured build agents to use larger P: drive for release builds to address disk space constraints. +- Enhanced DSC v3 support by organizing resource manifests in a dedicated subfolder with PATH configuration. +- Reduced installer bundle size by 6-7MB through centralized Hybrid CRT configuration across all C++ projects. +- Updated .NET packages to version 9.0.10 for security fixes. Thanks [@snickler](https://github.com/snickler)! +- Fixed spell check dictionary entries for consistency. +- Restored accidentally deleted NuGet configuration file for Command Palette extensions. +- Fixed package identity build by updating AppxManifest entry points to use PowerShell Core. +- Optimized CI pipeline by replacing file copy operations with hard links and moves, reducing build time and disk usage by 10-15GB. +- Updated Copilot guidance and PR prompt workflow. +- Included high-volume bugs in issue template header. Thanks [@daverayment](https://github.com/daverayment)! +- Fixed incorrect HRESULT logging for inner exceptions. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Introduced shared sparse package identity for PowerToys Win32 components to enable access to Windows platform APIs. +- Consolidated installer builds to produce both machine and user installers simultaneously, reducing build time and complexity. +- Migrated exclusively to WiX v5 installer infrastructure, removing legacy WiX v3 support. +- Temporarily removed PowerToys installer path from PATH environment variable to prevent application crashes. +- Added complete OCR UI test coverage with automated tests for activation, settings, language selection, and text extraction. +- Fixed test input for drive path normalization in bookmark resolver unit tests. +- Fixed Peek UI tests by restoring Ctrl+Space activation shortcut for test scenarios. +- Hided apps in PowerToys.SpareApps package from Start Menu. Thanks [@jiripolasek](https://github.com/jiripolasek)! ## 🛣️ Roadmap We are planning some nice new features and improvements for the next releases – a revamped Keyboard Manager UI, custom endpoint and local model support for Advanced Paste, Command Palette improvements and a brand-new Shortcut Guide experience! Stay tuned for [v0.96][github-next-release-work]! diff --git a/deps/cziplib b/deps/cziplib deleted file mode 160000 index 81314fff0a..0000000000 --- a/deps/cziplib +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 81314fff0a882b72a9ad321e7a3311660125b56e diff --git a/doc/devdocs/modules/lightswitch.md b/doc/devdocs/modules/lightswitch.md index 1e251dfff1..18192f7f23 100644 --- a/doc/devdocs/modules/lightswitch.md +++ b/doc/devdocs/modules/lightswitch.md @@ -33,9 +33,12 @@ The **Light Switch** module lets users automatically transition between light an > **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. - +* **LightSwitchService.cpp** + is the heart beat of the module. Controls ticking every minute and depending on user actions (manual override, settings changing, etc) triggers the state manager to perform the corresponding operation. + +* **LightSwitchStateManager.cpp** + handles updating the state based on the signals sent by LightSwitchService. + * **SettingsXAML/LightSwitch** Provides the settings UI for configuring schedules, syncing location, and customizing shortcuts. diff --git a/doc/images/icons/CursorWrap.png b/doc/images/icons/CursorWrap.png new file mode 100644 index 0000000000..20db84fc9a Binary files /dev/null and b/doc/images/icons/CursorWrap.png differ diff --git a/doc/images/icons/Find My Mouse.png b/doc/images/icons/Find My Mouse.png index 71dd994569..82fbe59800 100644 Binary files a/doc/images/icons/Find My Mouse.png and b/doc/images/icons/Find My Mouse.png differ diff --git a/doc/images/icons/Mouse Highlighter.png b/doc/images/icons/Mouse Highlighter.png index b06843d941..0feb5cc15a 100644 Binary files a/doc/images/icons/Mouse Highlighter.png and b/doc/images/icons/Mouse Highlighter.png differ diff --git a/doc/images/icons/MouseJump.png b/doc/images/icons/MouseJump.png new file mode 100644 index 0000000000..2fbe450ac2 Binary files /dev/null and b/doc/images/icons/MouseJump.png differ diff --git a/doc/images/icons/MouseWithoutBorders.png b/doc/images/icons/MouseWithoutBorders.png index a29adf7d11..ee66893cbd 100644 Binary files a/doc/images/icons/MouseWithoutBorders.png and b/doc/images/icons/MouseWithoutBorders.png differ diff --git a/installer/PowerToysSetup/generateDscResourcesWxs.ps1 b/installer/PowerToysSetup/generateDscResourcesWxs.ps1 deleted file mode 100644 index f76fd71953..0000000000 --- a/installer/PowerToysSetup/generateDscResourcesWxs.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -[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 -$buildOutputDir = Join-Path $scriptDir "..\..\$Platform\$Configuration" - -if (-not (Test-Path $buildOutputDir)) { - Write-Error "Build output directory not found: '$buildOutputDir'" - exit 1 -} - -$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'" - $wxsContent = @" - - - - - - - -"@ - 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 0cfc3b1765..968fcc2530 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "../../src/common/logger/logger.h" #include "../../src/common/utils/gpo.h" @@ -856,14 +857,69 @@ UINT __stdcall UnsetAdvancedPasteAPIKeyCA(MSIHANDLE hInstall) try { - winrt::Windows::Security::Credentials::PasswordVault vault; - winrt::Windows::Security::Credentials::PasswordCredential cred; - hr = WcaInitialize(hInstall, "UnsetAdvancedPasteAPIKey"); ExitOnFailure(hr, "Failed to initialize"); - cred = vault.Retrieve(L"https://platform.openai.com/api-keys", L"PowerToys_AdvancedPaste_OpenAIKey"); - vault.Remove(cred); + winrt::Windows::Security::Credentials::PasswordVault vault; + + auto hasPrefix = [](std::wstring_view value, wchar_t const* prefix) { + std::wstring_view prefixView{ prefix }; + return value.compare(0, prefixView.size(), prefixView) == 0; + }; + + const wchar_t* resourcePrefixes[] = { + L"https://platform.openai.com/api-keys", + L"https://azure.microsoft.com/products/ai-services/openai-service", + L"https://azure.microsoft.com/products/ai-services/ai-inference", + L"https://console.mistral.ai/account/api-keys", + L"https://ai.google.dev/", + }; + + const wchar_t* usernamePrefixes[] = { + L"PowerToys_AdvancedPaste_", + }; + + auto credentials = vault.RetrieveAll(); + for (auto const& credential : credentials) + { + bool shouldRemove = false; + + std::wstring resource{ credential.Resource() }; + for (auto const prefix : resourcePrefixes) + { + if (hasPrefix(resource, prefix)) + { + shouldRemove = true; + break; + } + } + + if (!shouldRemove) + { + std::wstring username{ credential.UserName() }; + for (auto const prefix : usernamePrefixes) + { + if (hasPrefix(username, prefix)) + { + shouldRemove = true; + break; + } + } + } + + if (!shouldRemove) + { + continue; + } + + try + { + vault.Remove(credential); + } + catch (...) + { + } + } } catch (...) { diff --git a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj index 22d8227446..23e69c9d93 100644 --- a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj +++ b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj @@ -54,6 +54,7 @@ call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\CmdPal.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\CmdPal.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\ColorPicker.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\ColorPicker.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Core.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Core.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\DscResources.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\DscResources.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\EnvironmentVariables.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\EnvironmentVariables.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\FileExplorerPreview.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\FileExplorerPreview.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\FileLocksmith.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\FileLocksmith.wxs.bk"""" @@ -109,6 +110,7 @@ Disabled _DEBUG;_WINDOWS;_USRDLL;CUSTOMACTIONTEST_EXPORTS;%(PreprocessorDefinitions) EnableFastChecks + MultiThreadedDebug true @@ -121,6 +123,7 @@ MaxSpeed true NDEBUG;_WINDOWS;_USRDLL;CUSTOMACTIONTEST_EXPORTS;%(PreprocessorDefinitions) + MultiThreaded true diff --git a/installer/PowerToysSetupVNext/Core.wxs b/installer/PowerToysSetupVNext/Core.wxs index f7da6162f9..a9cf083512 100644 --- a/installer/PowerToysSetupVNext/Core.wxs +++ b/installer/PowerToysSetupVNext/Core.wxs @@ -15,8 +15,8 @@ - - + + @@ -24,8 +24,8 @@ - - + + @@ -63,16 +63,6 @@ - - - - - - - - - - @@ -120,7 +110,6 @@ - @@ -128,16 +117,15 @@ - - - - - - + + + + + diff --git a/installer/PowerToysSetupVNext/Directory.Build.props b/installer/PowerToysSetupVNext/Directory.Build.props index 69a63832d1..505e3cf844 100644 --- a/installer/PowerToysSetupVNext/Directory.Build.props +++ b/installer/PowerToysSetupVNext/Directory.Build.props @@ -1,5 +1,4 @@ - @@ -9,4 +8,4 @@ $(BaseIntermediateOutputPath) - \ No newline at end of file + diff --git a/installer/PowerToysSetupVNext/DscResources.wxs b/installer/PowerToysSetupVNext/DscResources.wxs new file mode 100644 index 0000000000..2c08253229 --- /dev/null +++ b/installer/PowerToysSetupVNext/DscResources.wxs @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index c99bae67a3..18d6232140 100644 --- a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -14,7 +14,6 @@ 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)" @@ -25,7 +24,6 @@ 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)" @@ -37,6 +35,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil call move /Y ..\..\..\CmdPal.wxs.bk ..\..\..\CmdPal.wxs call move /Y ..\..\..\ColorPicker.wxs.bk ..\..\..\ColorPicker.wxs call move /Y ..\..\..\Core.wxs.bk ..\..\..\Core.wxs + call move /Y ..\..\..\DscResources.wxs.bk ..\..\..\DscResources.wxs call move /Y ..\..\..\EnvironmentVariables.wxs.bk ..\..\..\EnvironmentVariables.wxs call move /Y ..\..\..\FileExplorerPreview.wxs.bk ..\..\..\FileExplorerPreview.wxs call move /Y ..\..\..\FileLocksmith.wxs.bk ..\..\..\FileLocksmith.wxs diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs index 556fddc7f4..3e812beb2e 100644 --- a/installer/PowerToysSetupVNext/Product.wxs +++ b/installer/PowerToysSetupVNext/Product.wxs @@ -120,8 +120,8 @@ - - + + diff --git a/installer/PowerToysSetupVNext/Settings.wxs b/installer/PowerToysSetupVNext/Settings.wxs index f9e5312ea7..cf7cf7f727 100644 --- a/installer/PowerToysSetupVNext/Settings.wxs +++ b/installer/PowerToysSetupVNext/Settings.wxs @@ -14,11 +14,16 @@ + + + - + + + @@ -45,6 +50,11 @@ + + + + + @@ -67,6 +77,7 @@ + diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj index d45e32f87c..3972c1b0f7 100644 --- a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj @@ -1,7 +1,30 @@ + + + + + Debug + ARM64 + + + Release + ARM64 + + + Debug + x64 + + + Release + x64 + + + {F8B9F842-F5C3-4A2D-8C85-7F8B9E2B4F1D} + DynamicLibrary + Unicode SilentFilesInUseBAFunction PowerToysSetupCustomActionsVNext bafunctions.def @@ -10,6 +33,7 @@ + DynamicLibrary true @@ -41,10 +65,7 @@ - - Use - precomp.h - + Create precomp.h @@ -71,5 +92,31 @@ + + + + _DEBUG;%(PreprocessorDefinitions) + Disabled + MultiThreadedDebug + + + true + + + + + NDEBUG;%(PreprocessorDefinitions) + MaxSpeed + MultiThreaded + true + true + + + true + true + true + + + diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp index ceccde5f0d..9b9e5d570f 100644 --- a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp @@ -18,6 +18,7 @@ public: // IBootstrapperApplication BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION SYSTEM ACTIVE *** Running detect begin BA function. fCached=%d, registrationType=%d, cPackages=%u, fCancel=%d", fCached, registrationType, cPackages, *pfCancel); + LExit: return hr; } @@ -31,6 +32,12 @@ public: // IBAFunctions BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION SYSTEM ACTIVE *** Running plan begin BA function. cPackages=%u, fCancel=%d", cPackages, *pfCancel); + //------------------------------------------------------------------------------------------------- + // YOUR CODE GOES HERE + // BalExitOnFailure(hr, "Change this message to represent real error handling."); + //------------------------------------------------------------------------------------------------- + + LExit: return hr; } @@ -56,7 +63,6 @@ public: // IBAFunctions ) { HRESULT hr = S_OK; - UNREFERENCED_PARAMETER(source); BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION CALLED *** Running OnExecuteFilesInUse BA function. packageId=%ls, cFiles=%u, recommendation=%d", wzPackageId, cFiles, nRecommendation); diff --git a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 index 440cf38418..6724d95170 100644 --- a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 +++ b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 @@ -307,11 +307,17 @@ Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsFiles -wxsFileP Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\OOBE\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsFluentIconsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\" +Generate-FileList -fileDepsJson "" -fileListName SettingsV2IconsModelsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\Models\" Generate-FileComponents -fileListName "SettingsV2AssetsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs Generate-FileComponents -fileListName "SettingsV2AssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs Generate-FileComponents -fileListName "SettingsV2OOBEAssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs Generate-FileComponents -fileListName "SettingsV2OOBEAssetsFluentIconsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2IconsModelsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs #Workspaces Generate-FileList -fileDepsJson "" -fileListName WorkspacesImagesComponentFiles -wxsFilePath $PSScriptRoot\Workspaces.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Workspaces\" Generate-FileComponents -fileListName "WorkspacesImagesComponentFiles" -wxsFilePath $PSScriptRoot\Workspaces.wxs + +#DSC Resources - JSON manifest files in DSCModules subfolder +Generate-FileList -fileDepsJson "" -fileListName DscJsonFiles -wxsFilePath $PSScriptRoot\DscResources.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\DSCModules\" +Generate-FileComponents -fileListName "DscJsonFiles" -wxsFilePath $PSScriptRoot\DscResources.wxs diff --git a/installer/PowerToysSetupVNext/generateDscResourcesWxs.ps1 b/installer/PowerToysSetupVNext/generateDscResourcesWxs.ps1 deleted file mode 100644 index 14172db0bc..0000000000 --- a/installer/PowerToysSetupVNext/generateDscResourcesWxs.ps1 +++ /dev/null @@ -1,102 +0,0 @@ -[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/src/PackageIdentity/AppxManifest.xml b/src/PackageIdentity/AppxManifest.xml index 2e9d52a2fa..822daae8bc 100644 --- a/src/PackageIdentity/AppxManifest.xml +++ b/src/PackageIdentity/AppxManifest.xml @@ -42,7 +42,8 @@ Description="PowerToys OCR Module" BackgroundColor="transparent" Square150x150Logo="Images\Square150x150Logo.png" - Square44x44Logo="Images\Square44x44Logo.png"> + Square44x44Logo="Images\Square44x44Logo.png" + AppListEntry="none"> @@ -51,7 +52,8 @@ Description="PowerToys Settings UI" BackgroundColor="transparent" Square150x150Logo="Images\Square150x150Logo.png" - Square44x44Logo="Images\Square44x44Logo.png"> + Square44x44Logo="Images\Square44x44Logo.png" + AppListEntry="none"> @@ -60,7 +62,8 @@ Description="PowerToys Image Resizer UI" BackgroundColor="transparent" Square150x150Logo="Images\Square150x150Logo.png" - Square44x44Logo="Images\Square44x44Logo.png"> + Square44x44Logo="Images\Square44x44Logo.png" + AppListEntry="none"> diff --git a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj index ff9332cfc0..43f4749892 100644 --- a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj +++ b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj @@ -9,6 +9,12 @@ CalculatorEngineCommon false + + + true + false + + true @@ -19,9 +25,11 @@ true - false + true true Windows Store + false + @@ -140,5 +148,43 @@ - + + + + + + + MultiThreadedDebug + stdcpp17 + + + + %(IgnoreSpecificDefaultLibraries);libucrtd.lib + %(AdditionalOptions) /defaultlib:ucrtd.lib + + + + + + MultiThreaded + + + + %(IgnoreSpecificDefaultLibraries);libucrt.lib + %(AdditionalOptions) /defaultlib:ucrt.lib + + + \ No newline at end of file diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 361255f66f..2b256cd926 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -112,6 +112,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredMousePointerCrosshairsEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredCursorWrapEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredCursorWrapEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredPowerRenameEnabledValue() { return static_cast(powertoys_gpo::getConfiguredPowerRenameEnabledValue()); @@ -192,6 +196,34 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue()); } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteOpenAIValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteOpenAIValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAzureOpenAIValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteAzureOpenAIValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAzureAIInferenceValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteAzureAIInferenceValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteMistralValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteMistralValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteGoogleValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteGoogleValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteOllamaValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteOllamaValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteFoundryLocalValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusEnabledValue() { return static_cast(powertoys_gpo::getConfiguredNewPlusEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index c0fff9f542..e57cccccd9 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -35,6 +35,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); static GpoRuleConfigured GetConfiguredQuickAccentEnabledValue(); @@ -54,6 +55,13 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredQoiPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredQoiThumbnailsEnabledValue(); static GpoRuleConfigured GetAllowedAdvancedPasteOnlineAIModelsValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureAIInferenceValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteMistralValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue(); static GpoRuleConfigured GetConfiguredNewPlusEnabledValue(); static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue(); static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 630beab9c9..06d035aa35 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -38,6 +38,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredMouseWithoutBordersEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); @@ -58,6 +59,13 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredQoiPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredQoiThumbnailsEnabledValue(); static GpoRuleConfigured GetAllowedAdvancedPasteOnlineAIModelsValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureAIInferenceValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteMistralValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue(); static GpoRuleConfigured GetConfiguredNewPlusEnabledValue(); static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue(); static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue(); diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs new file mode 100644 index 0000000000..489a779179 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record FoundryCachedModel(string Name, string? Id); diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs new file mode 100644 index 0000000000..413bb47316 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record FoundryCatalogModel +{ + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; init; } = string.Empty; + + [JsonPropertyName("providerType")] + public string ProviderType { get; init; } = string.Empty; + + [JsonPropertyName("uri")] + public string Uri { get; init; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; init; } = string.Empty; + + [JsonPropertyName("modelType")] + public string ModelType { get; init; } = string.Empty; + + [JsonPropertyName("promptTemplate")] + public PromptTemplate PromptTemplate { get; init; } = default!; + + [JsonPropertyName("publisher")] + public string Publisher { get; init; } = string.Empty; + + [JsonPropertyName("task")] + public string Task { get; init; } = string.Empty; + + [JsonPropertyName("runtime")] + public Runtime Runtime { get; init; } = default!; + + [JsonPropertyName("fileSizeMb")] + public long FileSizeMb { get; init; } + + [JsonPropertyName("modelSettings")] + public ModelSettings ModelSettings { get; init; } = default!; + + [JsonPropertyName("alias")] + public string Alias { get; init; } = string.Empty; + + [JsonPropertyName("supportsToolCalling")] + public bool SupportsToolCalling { get; init; } + + [JsonPropertyName("license")] + public string License { get; init; } = string.Empty; + + [JsonPropertyName("licenseDescription")] + public string LicenseDescription { get; init; } = string.Empty; + + [JsonPropertyName("parentModelUri")] + public string ParentModelUri { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs new file mode 100644 index 0000000000..a279f7389a --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using ManagedCommon; +using Microsoft.AI.Foundry.Local; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed class FoundryClient +{ + public static async Task CreateAsync() + { + // First attempt with current environment + var client = await TryCreateClientAsync().ConfigureAwait(false); + if (client != null) + { + return client; + } + + // If failed, refresh PATH from registry and retry once + // This handles cases where PowerToys was launched by MSI installer. + Logger.LogInfo("[FoundryClient] First attempt failed, refreshing PATH and retrying"); + RefreshEnvironmentPath(); + + return await TryCreateClientAsync().ConfigureAwait(false); + } + + private static async Task TryCreateClientAsync() + { + try + { + Logger.LogInfo("[FoundryClient] Creating Foundry Local client"); + + var manager = new FoundryLocalManager(); + + // Check if service is already running + if (manager.IsServiceRunning) + { + Logger.LogInfo("[FoundryClient] Foundry service is already running"); + return new FoundryClient(manager); + } + + // Start the service using SDK's method + Logger.LogInfo("[FoundryClient] Starting Foundry service using manager.StartServiceAsync()"); + await manager.StartServiceAsync().ConfigureAwait(false); + + Logger.LogInfo("[FoundryClient] Foundry service started successfully"); + return new FoundryClient(manager); + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error creating client: {ex.Message}"); + if (ex.InnerException != null) + { + Logger.LogError($"[FoundryClient] Inner exception: {ex.InnerException.Message}"); + } + + return null; + } + } + + private readonly FoundryLocalManager _foundryManager; + private readonly List _catalogModels = []; + + private FoundryClient(FoundryLocalManager foundryManager) + { + _foundryManager = foundryManager; + } + + public Task GetServiceUrl() + { + try + { + return Task.FromResult(_foundryManager.Endpoint?.ToString()); + } + catch + { + return Task.FromResult(null); + } + } + + public Uri? GetServiceUri() + { + try + { + return _foundryManager.ServiceUri; + } + catch + { + return null; + } + } + + public async Task> ListCatalogModels() + { + if (_catalogModels.Count > 0) + { + return _catalogModels; + } + + try + { + Logger.LogInfo("[FoundryClient] Listing catalog models"); + var models = await _foundryManager.ListCatalogModelsAsync().ConfigureAwait(false); + + if (models != null) + { + foreach (var model in models) + { + _catalogModels.Add(new FoundryCatalogModel + { + Name = model.ModelId ?? string.Empty, + DisplayName = model.DisplayName ?? string.Empty, + ProviderType = model.ProviderType ?? string.Empty, + Uri = model.Uri ?? string.Empty, + Version = model.Version ?? string.Empty, + ModelType = model.ModelType ?? string.Empty, + Publisher = model.Publisher ?? string.Empty, + Task = model.Task ?? string.Empty, + FileSizeMb = model.FileSizeMb, + Alias = model.Alias ?? string.Empty, + License = model.License ?? string.Empty, + LicenseDescription = model.LicenseDescription ?? string.Empty, + ParentModelUri = model.ParentModelUri ?? string.Empty, + SupportsToolCalling = model.SupportsToolCalling, + }); + } + + Logger.LogInfo($"[FoundryClient] Found {_catalogModels.Count} catalog models"); + } + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error listing catalog models: {ex.Message}"); + + // Surfacing errors here prevents listing other providers; swallow and return cached list instead. + } + + return _catalogModels; + } + + public async Task> ListCachedModels() + { + try + { + Logger.LogInfo("[FoundryClient] Listing cached models"); + var cachedModels = await _foundryManager.ListCachedModelsAsync().ConfigureAwait(false); + var catalogModels = await ListCatalogModels().ConfigureAwait(false); + + List models = []; + + foreach (var model in cachedModels) + { + var catalogModel = catalogModels.FirstOrDefault(m => m.Name == model.ModelId); + var alias = catalogModel?.Alias ?? model.Alias; + models.Add(new FoundryCachedModel(model.ModelId ?? string.Empty, alias)); + } + + Logger.LogInfo($"[FoundryClient] Found {models.Count} cached models"); + return models; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error listing cached models: {ex.Message}"); + return []; + } + } + + public async Task IsModelLoaded(string modelId) + { + try + { + var loadedModels = await _foundryManager.ListLoadedModelsAsync().ConfigureAwait(false); + var isLoaded = loadedModels.Any(m => m.ModelId == modelId); + Logger.LogInfo($"[FoundryClient] IsModelLoaded({modelId}): {isLoaded}"); + Logger.LogInfo($"[FoundryClient] Loaded models: {string.Join(", ", loadedModels.Select(m => m.ModelId))}"); + return isLoaded; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] IsModelLoaded exception: {ex.Message}"); + return false; + } + } + + public async Task EnsureModelLoaded(string modelId) + { + Logger.LogInfo($"[FoundryClient] EnsureModelLoaded called with: {modelId}"); + + // Check if already loaded + if (await IsModelLoaded(modelId).ConfigureAwait(false)) + { + Logger.LogInfo($"[FoundryClient] Model already loaded: {modelId}"); + return true; + } + + // Load the model + Logger.LogInfo($"[FoundryClient] Loading model: {modelId}"); + await _foundryManager.LoadModelAsync(modelId).ConfigureAwait(false); + + // Verify it's loaded + var loaded = await IsModelLoaded(modelId).ConfigureAwait(false); + Logger.LogInfo($"[FoundryClient] Model load result: {loaded}"); + return loaded; + } + + public async Task EnsureRunning() + { + if (!_foundryManager.IsServiceRunning) + { + await _foundryManager.StartServiceAsync(); + } + } + + /// + /// Refreshes the PATH environment variable from the system registry. + /// This is necessary when tools are installed while PowerToys is running, + /// as the installer updates the system PATH but running processes don't see the change. + /// + private static void RefreshEnvironmentPath() + { + try + { + Logger.LogInfo("[FoundryClient] Refreshing PATH environment variable from system"); + + var currentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process) ?? string.Empty; + var machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? string.Empty; + var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + + var pathsToAdd = new List(); + + if (!string.IsNullOrWhiteSpace(currentPath)) + { + pathsToAdd.AddRange(currentPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)); + } + + if (!string.IsNullOrWhiteSpace(userPath)) + { + var userPaths = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in userPaths) + { + if (!pathsToAdd.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + pathsToAdd.Add(path); + } + } + } + + if (!string.IsNullOrWhiteSpace(machinePath)) + { + var machinePaths = machinePath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in machinePaths) + { + if (!pathsToAdd.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + pathsToAdd.Add(path); + } + } + } + + var newPath = string.Join(Path.PathSeparator.ToString(), pathsToAdd); + + if (currentPath != newPath) + { + Logger.LogInfo("[FoundryClient] Updating process PATH with latest system values"); + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); + } + else + { + Logger.LogInfo("[FoundryClient] PATH is already up to date"); + } + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Failed to refresh PATH: {ex.Message}"); + } + } +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs new file mode 100644 index 0000000000..5dcb4076ed --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = false)] +[JsonSerializable(typeof(FoundryCatalogModel))] +[JsonSerializable(typeof(List))] +internal sealed partial class FoundryJsonContext : JsonSerializerContext +{ +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs b/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs new file mode 100644 index 0000000000..fda91217eb --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record ModelSettings +{ + // The sample shows an empty array; keep it open-ended. + [JsonPropertyName("parameters")] + public List Parameters { get; init; } = []; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs b/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs new file mode 100644 index 0000000000..a2cbb9fe45 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record PromptTemplate +{ + [JsonPropertyName("assistant")] + public string Assistant { get; init; } = string.Empty; + + [JsonPropertyName("prompt")] + public string Prompt { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs b/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs new file mode 100644 index 0000000000..e2019c8f87 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record Runtime +{ + [JsonPropertyName("deviceType")] + public string DeviceType { get; init; } = string.Empty; + + [JsonPropertyName("executionProvider")] + public string ExecutionProvider { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs new file mode 100644 index 0000000000..5158e4334e --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ClientModel; +using LanguageModelProvider.FoundryLocal; +using ManagedCommon; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace LanguageModelProvider; + +public sealed class FoundryLocalModelProvider : ILanguageModelProvider +{ + private FoundryClient? _foundryClient; + private IEnumerable? _catalogModels; + private string? _serviceUrl; + + public static FoundryLocalModelProvider Instance { get; } = new(); + + public string Name => "FoundryLocal"; + + public string ProviderDescription => "The model will run locally via Foundry Local"; + + public IChatClient? GetIChatClient(string modelId) + { + Logger.LogInfo($"[FoundryLocal] GetIChatClient called with url: {modelId}"); + InitializeAsync().GetAwaiter().GetResult(); + + if (string.IsNullOrWhiteSpace(modelId)) + { + Logger.LogError("[FoundryLocal] Model ID is empty after extraction"); + return null; + } + + // Check if model is in catalog + var isInCatalog = _catalogModels?.Any(m => m.Name == modelId) ?? false; + if (!isInCatalog) + { + var errorMessage = $"{modelId} is not supported in Foundry Local. Please configure supported models in Settings."; + Logger.LogError($"[FoundryLocal] {errorMessage}"); + throw new InvalidOperationException(errorMessage); + } + + // Ensure the model is loaded before returning chat client + var isLoaded = _foundryClient!.EnsureModelLoaded(modelId).GetAwaiter().GetResult(); + if (!isLoaded) + { + Logger.LogError($"[FoundryLocal] Failed to load model: {modelId}"); + throw new InvalidOperationException($"Failed to load the model '{modelId}'."); + } + + // Use ServiceUri instead of Endpoint since Endpoint already includes /v1 + var baseUri = _foundryClient.GetServiceUri(); + if (baseUri == null) + { + const string message = "Foundry Local service URL is not available. Please make sure Foundry Local is installed and running."; + Logger.LogError($"[FoundryLocal] {message}"); + throw new InvalidOperationException(message); + } + + var endpointUri = new Uri($"{baseUri.ToString().TrimEnd('/')}/v1"); + Logger.LogInfo($"[FoundryLocal] Creating OpenAI client with endpoint: {endpointUri}"); + + return new OpenAIClient( + new ApiKeyCredential("none"), + new OpenAIClientOptions { Endpoint = endpointUri, NetworkTimeout = TimeSpan.FromMinutes(5) }) + .GetChatClient(modelId) + .AsIChatClient(); + } + + public string GetIChatClientString(string url) + { + try + { + InitializeAsync().GetAwaiter().GetResult(); + } + catch + { + return string.Empty; + } + + var modelId = url.Split('/').LastOrDefault(); + + if (string.IsNullOrWhiteSpace(_serviceUrl) || string.IsNullOrWhiteSpace(modelId)) + { + return string.Empty; + } + + return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{_serviceUrl}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()"; + } + + public async Task> GetModelsAsync(CancellationToken cancelationToken = default) + { + await InitializeAsync(cancelationToken); + + if (_foundryClient == null) + { + return Array.Empty(); + } + + var cachedModels = await _foundryClient.ListCachedModels(); + List downloadedModels = []; + + foreach (var model in cachedModels) + { + Logger.LogInfo($"[FoundryLocal] Adding unmatched cached model: {model.Name}"); + downloadedModels.Add(new ModelDetails + { + Id = $"fl-{model.Name}", + Name = model.Name, + Url = $"fl://{model.Name}", + Description = $"{model.Name} running locally with Foundry Local", + HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL], + ProviderModelDetails = model, + }); + } + + return downloadedModels; + } + + private async Task InitializeAsync(CancellationToken cancelationToken = default) + { + if (_foundryClient != null && _catalogModels != null && _catalogModels.Any()) + { + await _foundryClient.EnsureRunning().ConfigureAwait(false); + return; + } + + Logger.LogInfo("[FoundryLocal] Initializing provider"); + _foundryClient ??= await FoundryClient.CreateAsync(); + + if (_foundryClient == null) + { + const string message = "Foundry Local client could not be created. Please make sure Foundry Local is installed and running."; + Logger.LogError($"[FoundryLocal] {message}"); + throw new InvalidOperationException(message); + } + + _serviceUrl ??= await _foundryClient.GetServiceUrl(); + Logger.LogInfo($"[FoundryLocal] Service URL: {_serviceUrl}"); + + var catalogModels = await _foundryClient.ListCatalogModels(); + Logger.LogInfo($"[FoundryLocal] Found {catalogModels.Count} catalog models"); + _catalogModels = catalogModels; + } + + public async Task IsAvailable() + { + Logger.LogInfo("[FoundryLocal] Checking availability"); + await InitializeAsync(); + var available = _foundryClient != null; + Logger.LogInfo($"[FoundryLocal] Available: {available}"); + return available; + } +} diff --git a/src/common/LanguageModelProvider/HardwareAccelerator.cs b/src/common/LanguageModelProvider/HardwareAccelerator.cs new file mode 100644 index 0000000000..d2c94b8155 --- /dev/null +++ b/src/common/LanguageModelProvider/HardwareAccelerator.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace LanguageModelProvider; + +public enum HardwareAccelerator +{ + CPU, + DML, + QNN, + WCRAPI, + OLLAMA, + OPENAI, + FOUNDRYLOCAL, + LEMONADE, + NPU, + GPU, + VitisAI, + OpenVINO, + NvTensorRT, +} diff --git a/src/common/LanguageModelProvider/ILanguageModelProvider.cs b/src/common/LanguageModelProvider/ILanguageModelProvider.cs new file mode 100644 index 0000000000..9d203adaf6 --- /dev/null +++ b/src/common/LanguageModelProvider/ILanguageModelProvider.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.AI; + +namespace LanguageModelProvider; + +public interface ILanguageModelProvider +{ + string Name { get; } + + string ProviderDescription { get; } + + Task> GetModelsAsync(CancellationToken cancelationToken = default); + + IChatClient? GetIChatClient(string modelId); + + string GetIChatClientString(string url); +} diff --git a/src/common/LanguageModelProvider/LanguageModelProvider.csproj b/src/common/LanguageModelProvider/LanguageModelProvider.csproj new file mode 100644 index 0000000000..4dba9247a3 --- /dev/null +++ b/src/common/LanguageModelProvider/LanguageModelProvider.csproj @@ -0,0 +1,20 @@ + + + + + + enable + enable + + + + + + + + + + + + + diff --git a/src/common/LanguageModelProvider/ModelDetails.cs b/src/common/LanguageModelProvider/ModelDetails.cs new file mode 100644 index 0000000000..e383aa7d27 --- /dev/null +++ b/src/common/LanguageModelProvider/ModelDetails.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace LanguageModelProvider; + +public class ModelDetails +{ + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public long Size { get; set; } + + public bool IsUserAdded { get; set; } + + public string Icon { get; set; } = string.Empty; + + public List HardwareAccelerators { get; set; } = []; + + public string License { get; set; } = string.Empty; + + public object? ProviderModelDetails { get; set; } +} diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index aa741e2f3a..d7ae386191 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -12,6 +12,7 @@ namespace ManagedCommon ColorPicker, CmdPal, CropAndLock, + CursorWrap, EnvironmentVariables, FancyZones, FileLocksmith, diff --git a/src/common/UITestAutomation/SettingsConfigHelper.cs b/src/common/UITestAutomation/SettingsConfigHelper.cs new file mode 100644 index 0000000000..833ec4f19d --- /dev/null +++ b/src/common/UITestAutomation/SettingsConfigHelper.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; + +namespace Microsoft.PowerToys.UITest +{ + /// + /// Helper class for configuring PowerToys settings for UI tests. + /// + public class SettingsConfigHelper + { + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; + private static readonly SettingsUtils SettingsUtils = new SettingsUtils(); + + /// + /// Configures global PowerToys settings to enable only specified modules and disable all others. + /// + /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. + /// Thrown when modulesToEnable is null. + /// Thrown when settings file operations fail. + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")] + public static void ConfigureGlobalModuleSettings(params string[] modulesToEnable) + { + ArgumentNullException.ThrowIfNull(modulesToEnable); + + try + { + GeneralSettings settings; + try + { + settings = SettingsUtils.GetSettingsOrDefault(); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to load settings, creating defaults: {ex.Message}"); + settings = new GeneralSettings(); + } + + string settingsJson = settings.ToJsonString(); + using (JsonDocument doc = JsonDocument.Parse(settingsJson)) + { + var options = new JsonSerializerOptions { WriteIndented = true }; + var root = doc.RootElement.Clone(); + + if (root.TryGetProperty("enabled", out var enabledElement)) + { + var enabledModules = new Dictionary(); + + foreach (var property in enabledElement.EnumerateObject()) + { + string moduleName = property.Name; + + bool shouldEnable = Array.Exists(modulesToEnable, m => string.Equals(m, moduleName, StringComparison.Ordinal)); + enabledModules[moduleName] = shouldEnable; + } + + var settingsDict = JsonSerializer.Deserialize>(settingsJson); + if (settingsDict != null) + { + settingsDict["enabled"] = enabledModules; + settingsJson = JsonSerializer.Serialize(settingsDict, IndentedJsonOptions); + } + } + } + + SettingsUtils.SaveSettings(settingsJson); + + string enabledList = modulesToEnable.Length > 0 ? string.Join(", ", modulesToEnable) : "none"; + Debug.WriteLine($"Successfully updated global settings"); + Debug.WriteLine($"Enabled modules: {enabledList}"); + } + catch (Exception ex) + { + Debug.WriteLine($"ERROR in ConfigureGlobalModuleSettings: {ex.Message}"); + throw new InvalidOperationException($"Failed to configure global module settings: {ex.Message}", ex); + } + } + + /// + /// Updates a module's settings file. If the file doesn't exist, creates it with default content. + /// If the file exists, reads it and applies the provided update function to modify the settings. + /// + /// The name of the module (e.g., "Peek", "FancyZones"). + /// The default JSON content to use if the settings file doesn't exist. + /// + /// A callback function that modifies the settings dictionary. The function receives the deserialized settings + /// and should modify it in-place. The function should accept a Dictionary<string, object> and not return a value. + /// Example: (settings) => { ((Dictionary<string, object>)settings["properties"])["SomeSetting"] = newValue; } + /// + /// Thrown when moduleName or updateSettingsAction is null. + /// Thrown when settings file operations fail. + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")] + public static void UpdateModuleSettings( + string moduleName, + string defaultSettingsContent, + Action> updateSettingsAction) + { + ArgumentNullException.ThrowIfNull(moduleName); + ArgumentNullException.ThrowIfNull(updateSettingsAction); + + try + { + // Build the path to the module settings file + string powerToysSettingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys"); + + string moduleDirectory = Path.Combine(powerToysSettingsDirectory, moduleName); + string settingsPath = Path.Combine(moduleDirectory, "settings.json"); + + // Ensure directory exists + Directory.CreateDirectory(moduleDirectory); + + // Read existing settings or use default + string existingJson = string.Empty; + if (File.Exists(settingsPath)) + { + existingJson = File.ReadAllText(settingsPath); + } + + Dictionary? settings; + + // If file doesn't exist or is empty, create from defaults + if (string.IsNullOrWhiteSpace(existingJson)) + { + if (string.IsNullOrWhiteSpace(defaultSettingsContent)) + { + throw new ArgumentException("Default settings content must be provided when file doesn't exist.", nameof(defaultSettingsContent)); + } + + settings = JsonSerializer.Deserialize>(defaultSettingsContent) + ?? throw new InvalidOperationException($"Failed to deserialize default settings for {moduleName}"); + + Debug.WriteLine($"Created default settings for {moduleName} at {settingsPath}"); + } + else + { + // Parse existing settings + settings = JsonSerializer.Deserialize>(existingJson) + ?? throw new InvalidOperationException($"Failed to deserialize existing settings for {moduleName}"); + + Debug.WriteLine($"Loaded existing settings for {moduleName} from {settingsPath}"); + } + + // Apply the update action to modify settings + updateSettingsAction(settings); + + // Serialize and save the updated settings using SettingsUtils + string updatedJson = JsonSerializer.Serialize(settings, IndentedJsonOptions); + SettingsUtils.SaveSettings(updatedJson, moduleName); + + Debug.WriteLine($"Successfully updated settings for {moduleName}"); + } + catch (Exception ex) + { + Debug.WriteLine($"ERROR in UpdateModuleSettings for {moduleName}: {ex.Message}"); + throw new InvalidOperationException($"Failed to update settings for {moduleName}: {ex.Message}", ex); + } + } + } +} diff --git a/src/common/UITestAutomation/UITestAutomation.csproj b/src/common/UITestAutomation/UITestAutomation.csproj index add7acfeb9..549b8a430b 100644 --- a/src/common/UITestAutomation/UITestAutomation.csproj +++ b/src/common/UITestAutomation/UITestAutomation.csproj @@ -8,7 +8,7 @@ enable true true - net9.0-windows10.0.22621.0 + net9.0-windows10.0.26100.0 true false @@ -21,4 +21,8 @@ + + + + diff --git a/src/common/interop/PowerToys.Interop.vcxproj b/src/common/interop/PowerToys.Interop.vcxproj index 4fc9be85a8..02a985b0c5 100644 --- a/src/common/interop/PowerToys.Interop.vcxproj +++ b/src/common/interop/PowerToys.Interop.vcxproj @@ -63,12 +63,14 @@ + MultiThreadedDebug true true + MultiThreaded false true false diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index b2e05fadfe..881633e05e 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -59,6 +59,7 @@ struct LogSettings inline const static std::string mouseHighlighterLoggerName = "mouse-highlighter"; inline const static std::string mouseJumpLoggerName = "mouse-jump"; inline const static std::string mousePointerCrosshairsLoggerName = "mouse-pointer-crosshairs"; + inline const static std::string cursorWrapLoggerName = "cursor-wrap"; inline const static std::string imageResizerLoggerName = "imageresizer"; inline const static std::string powerRenameLoggerName = "powerrename"; inline const static std::string alwaysOnTopLoggerName = "always-on-top"; diff --git a/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj b/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj index b2ebc7cb72..077333a664 100644 --- a/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj +++ b/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj @@ -46,6 +46,16 @@ notifications + + + MultiThreadedDebugDLL + + + + + MultiThreadedDLL + + $(IntDir)pch.pch diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index ca1cc5a99a..4afaff6f76 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace powertoys_gpo { @@ -51,6 +52,7 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_HIGHLIGHTER = L"ConfigureEnabledUtilityMouseHighlighter"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_JUMP = L"ConfigureEnabledUtilityMouseJump"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS = L"ConfigureEnabledUtilityMousePointerCrosshairs"; + const std::wstring POLICY_CONFIGURE_ENABLED_CURSOR_WRAP = L"ConfigureEnabledUtilityCursorWrap"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_RENAME = L"ConfigureEnabledUtilityPowerRename"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER = L"ConfigureEnabledUtilityPowerLauncher"; const std::wstring POLICY_CONFIGURE_ENABLED_QUICK_ACCENT = L"ConfigureEnabledUtilityQuickAccent"; @@ -82,6 +84,13 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_RUN_AT_STARTUP = L"ConfigureRunAtStartup"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER_ALL_PLUGINS = L"PowerLauncherAllPluginsEnabledState"; const std::wstring POLICY_ALLOW_ADVANCED_PASTE_ONLINE_AI_MODELS = L"AllowPowerToysAdvancedPasteOnlineAIModels"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OPENAI = L"AllowAdvancedPasteOpenAI"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_AZURE_OPENAI = L"AllowAdvancedPasteAzureOpenAI"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_AZURE_AI_INFERENCE = L"AllowAdvancedPasteAzureAIInference"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_MISTRAL = L"AllowAdvancedPasteMistral"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_GOOGLE = L"AllowAdvancedPasteGoogle"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OLLAMA = L"AllowAdvancedPasteOllama"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL = L"AllowAdvancedPasteFoundryLocal"; const std::wstring POLICY_MWB_CLIPBOARD_SHARING_ENABLED = L"MwbClipboardSharingEnabled"; const std::wstring POLICY_MWB_FILE_TRANSFER_ENABLED = L"MwbFileTransferEnabled"; const std::wstring POLICY_MWB_USE_ORIGINAL_USER_INTERFACE = L"MwbUseOriginalUserInterface"; @@ -235,6 +244,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS); } + inline gpo_rule_configured_t getConfiguredCursorWrapEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_CURSOR_WRAP); + } + inline gpo_rule_configured_t getConfiguredPowerRenameEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_POWER_RENAME); @@ -371,6 +385,41 @@ namespace powertoys_gpo return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_ONLINE_AI_MODELS); } + inline gpo_rule_configured_t getAllowedAdvancedPasteOpenAIValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_OPENAI); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAzureOpenAIValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_AZURE_OPENAI); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAzureAIInferenceValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_AZURE_AI_INFERENCE); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteMistralValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_MISTRAL); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteGoogleValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_GOOGLE); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteOllamaValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_OLLAMA); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteFoundryLocalValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL); + } + inline gpo_rule_configured_t getConfiguredMwbClipboardSharingEnabledValue() { return getConfiguredValue(POLICY_MWB_CLIPBOARD_SHARING_ENABLED); diff --git a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj index 3186a01d43..b36e602d25 100644 --- a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj +++ b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj @@ -33,9 +33,4 @@ - - - - - diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs index deae2eb832..45fd36d10e 100644 --- a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs @@ -23,7 +23,8 @@ public sealed class SettingsResourceAdvancedPasteModuleTest : SettingsResourceMo { s.Properties.ShowCustomPreview = !s.Properties.ShowCustomPreview; s.Properties.CloseAfterLosingFocus = !s.Properties.CloseAfterLosingFocus; - s.Properties.IsAdvancedAIEnabled = !s.Properties.IsAdvancedAIEnabled; + + // s.Properties.IsAdvancedAIEnabled = !s.Properties.IsAdvancedAIEnabled; s.Properties.AdvancedPasteUIShortcut = new HotkeySettings { Key = "mock", diff --git a/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs b/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs index dcb6abf4a1..5eb91acec3 100644 --- a/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs +++ b/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs @@ -13,7 +13,7 @@ namespace PowerToys.DSC.Models; 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 const string Executable = @"..\PowerToys.DSC.exe"; private readonly string _type; private readonly string _version; diff --git a/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj index 230cd4556b..9dc11a0a8a 100644 --- a/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj +++ b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj @@ -40,9 +40,11 @@ - - - - + + + + + + \ No newline at end of file diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index 07d4f44bde..4b77a6783f 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -1,11 +1,11 @@ - + - + @@ -26,6 +26,7 @@ + @@ -614,6 +615,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 2703358bb0..1bfa55866d 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -1,7 +1,7 @@ - + PowerToys PowerToys @@ -33,6 +33,7 @@ PowerToys version 0.88.0 or later PowerToys version 0.89.0 or later PowerToys version 0.90.0 or later + PowerToys version 0.96.0 or later From PowerToys version 0.64.0 until PowerToys version 0.87.1 This policy configures the enabled state for all PowerToys utilities. @@ -291,6 +292,54 @@ If you don't configure this policy, the user will be able to control the setting QOI file preview: Configure enabled state QOI file thumbnail: Configure enabled state Allow using online AI models + Advanced Paste: Allow OpenAI endpoint + This policy controls whether users can use the OpenAI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use OpenAI as their AI provider. + +If you disable this policy, users will not be able to select or use OpenAI endpoint in Advanced Paste settings. + Advanced Paste: Allow Azure OpenAI endpoint + This policy controls whether users can use the Azure OpenAI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Azure OpenAI as their AI provider. + +If you disable this policy, users will not be able to select or use Azure OpenAI endpoint in Advanced Paste settings. + Advanced Paste: Allow Azure AI Inference endpoint + This policy controls whether users can use the Azure AI Inference endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Azure AI Inference as their AI provider. + +If you disable this policy, users will not be able to select or use Azure AI Inference endpoint in Advanced Paste settings. + Advanced Paste: Allow Mistral endpoint + This policy controls whether users can use the Mistral AI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Mistral as their AI provider. + +If you disable this policy, users will not be able to select or use Mistral endpoint in Advanced Paste settings. + Advanced Paste: Allow Google endpoint + This policy controls whether users can use the Google (Gemini) endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Google as their AI provider. + +If you disable this policy, users will not be able to select or use Google endpoint in Advanced Paste settings. + Advanced Paste: Allow Anthropic endpoint + This policy controls whether users can use the Anthropic (Claude) endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Anthropic as their AI provider. + +If you disable this policy, users will not be able to select or use Anthropic endpoint in Advanced Paste settings. + Advanced Paste: Allow Ollama endpoint + This policy controls whether users can use the Ollama local model endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Ollama as their AI provider. + +If you disable this policy, users will not be able to select or use Ollama endpoint in Advanced Paste settings. + Advanced Paste: Allow Foundry Local endpoint + This policy controls whether users can use the Foundry Local model endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Foundry Local as their AI provider. + +If you disable this policy, users will not be able to select or use Foundry Local endpoint in Advanced Paste settings. Clipboard sharing enabled File transfer enabled Original user interface is available diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs new file mode 100644 index 0000000000..4446e24dde --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.UnitTests.Mocks; + +/// +/// Minimal implementation used by integration tests that +/// need to construct the runtime Advanced Paste services. +/// +internal sealed class IntegrationTestUserSettings : IUserSettings +{ + private readonly PasteAIConfiguration _configuration; + private readonly IReadOnlyList _customActions; + private readonly IReadOnlyList _additionalActions; + + public IntegrationTestUserSettings() + { + var provider = new PasteAIProviderDefinition + { + Id = "integration-openai", + EnableAdvancedAI = true, + ServiceTypeKind = AIServiceType.OpenAI, + ModelName = "gpt-4o", + ModerationEnabled = true, + }; + + _configuration = new PasteAIConfiguration + { + ActiveProviderId = provider.Id, + Providers = new ObservableCollection { provider }, + }; + + _customActions = Array.Empty(); + _additionalActions = Array.Empty(); + } + + public bool IsAIEnabled => true; + + public bool ShowCustomPreview => false; + + public bool CloseAfterLosingFocus => false; + + public bool EnableClipboardPreview => true; + + public IReadOnlyList CustomActions => _customActions; + + public IReadOnlyList AdditionalActions => _additionalActions; + + public PasteAIConfiguration PasteAIConfiguration => _configuration; + + public event EventHandler Changed; + + public Task SetActiveAIProviderAsync(string providerId) + { + _configuration.ActiveProviderId = providerId ?? string.Empty; + Changed?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs index 3782b057f1..17b8139bad 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs @@ -13,6 +13,8 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Services.OpenAI; using AdvancedPaste.UnitTests.Mocks; using ManagedCommon; @@ -79,7 +81,9 @@ public sealed class AIServiceBatchIntegrationTests Assert.IsTrue(results.Count <= inputs.Count); CollectionAssert.AreEqual(results.Select(result => result.ToInput()).ToList(), inputs.Take(results.Count).ToList()); + #pragma warning disable IL2026, IL3050 // The tests rely on runtime JSON serialization for ad-hoc data files. async Task WriteResultsAsync() => await File.WriteAllTextAsync(resultsFile, JsonSerializer.Serialize(results, SerializerOptions)); + #pragma warning restore IL2026, IL3050 Logger.LogInfo($"Starting {nameof(TestGenerateBatchResults)}; Count={inputs.Count}, InCache={results.Count}"); @@ -101,8 +105,12 @@ public sealed class AIServiceBatchIntegrationTests await WriteResultsAsync(); } - private static async Task> GetDataListAsync(string filePath) => - File.Exists(filePath) ? JsonSerializer.Deserialize>(await File.ReadAllTextAsync(filePath)) : []; + private static async Task> GetDataListAsync(string filePath) + { + #pragma warning disable IL2026, IL3050 // Tests only run locally and can depend on runtime JSON serialization. + return File.Exists(filePath) ? JsonSerializer.Deserialize>(await File.ReadAllTextAsync(filePath)) : []; + #pragma warning restore IL2026, IL3050 + } private static async Task GetTextOutputAsync(BatchTestInput input, PasteFormats format) { @@ -130,23 +138,35 @@ public sealed class AIServiceBatchIntegrationTests private static async Task GetOutputDataPackageAsync(BatchTestInput batchTestInput, PasteFormats format) { - VaultCredentialsProvider credentialsProvider = new(); - PromptModerationService promptModerationService = new(credentialsProvider); + var services = CreateServices(); NoOpProgress progress = new(); - CustomTextTransformService customTextTransformService = new(credentialsProvider, promptModerationService); switch (format) { case PasteFormats.CustomTextTransformation: - return DataPackageHelpers.CreateFromText(await customTextTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress)); + var transformResult = await services.CustomActionTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress); + return DataPackageHelpers.CreateFromText(transformResult.Content ?? string.Empty); case PasteFormats.KernelQuery: var clipboardData = DataPackageHelpers.CreateFromText(batchTestInput.Clipboard).GetView(); - KernelService kernelService = new(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, customTextTransformService); - return await kernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); + return await services.KernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); default: throw new InvalidOperationException($"Unexpected format {format}"); } } + + private static IntegrationTestServices CreateServices() + { + IntegrationTestUserSettings userSettings = new(); + EnhancedVaultCredentialsProvider credentialsProvider = new(userSettings); + PromptModerationService promptModerationService = new(credentialsProvider); + PasteAIProviderFactory providerFactory = new(); + ICustomActionTransformService customActionTransformService = new CustomActionTransformService(promptModerationService, providerFactory, credentialsProvider, userSettings); + IKernelService kernelService = new AdvancedAIKernelService(credentialsProvider, new NoOpKernelQueryCacheService(), promptModerationService, userSettings, customActionTransformService); + + return new IntegrationTestServices(customActionTransformService, kernelService); + } + + private readonly record struct IntegrationTestServices(ICustomActionTransformService CustomActionTransformService, IKernelService KernelService); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs index 998534cf5e..7c16089cd5 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs @@ -11,6 +11,8 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Services.OpenAI; using AdvancedPaste.Telemetry; using AdvancedPaste.UnitTests.Mocks; @@ -27,16 +29,19 @@ namespace AdvancedPaste.UnitTests.ServicesTests; public sealed class KernelServiceIntegrationTests : IDisposable { private const string StandardImageFile = "image_with_text_example.png"; - private KernelService _kernelService; + private IKernelService _kernelService; private AdvancedPasteEventListener _eventListener; [TestInitialize] public void TestInitialize() { - VaultCredentialsProvider credentialsProvider = new(); + IntegrationTestUserSettings userSettings = new(); + EnhancedVaultCredentialsProvider credentialsProvider = new(userSettings); PromptModerationService promptModerationService = new(credentialsProvider); + PasteAIProviderFactory providerFactory = new(); + CustomActionTransformService customActionTransformService = new(promptModerationService, providerFactory, credentialsProvider, userSettings); - _kernelService = new KernelService(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, new CustomTextTransformService(credentialsProvider, promptModerationService)); + _kernelService = new AdvancedAIKernelService(credentialsProvider, new NoOpKernelQueryCacheService(), promptModerationService, userSettings, customActionTransformService); _eventListener = new(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj index fba18de07c..1c80479c2d 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj @@ -33,11 +33,14 @@ + + + @@ -49,7 +52,6 @@ - @@ -57,10 +59,15 @@ + + + + + @@ -102,6 +109,7 @@ + @@ -114,9 +122,38 @@ true + + + MSBuild:Compile + + MSBuild:Compile + + + MSBuild:Compile + + + + + + + Assets\Settings\Icons\Models\%(Filename)%(Extension) + PreserveNewest + + + + PreserveNewest + + + PreserveNewest + + + + PreserveNewest + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml index da79c36a11..df6ed811ac 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml @@ -9,6 +9,7 @@ + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs index 3ac3baa9d0..3fa940952e 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs @@ -10,10 +10,10 @@ using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Settings; using AdvancedPaste.ViewModels; using ManagedCommon; @@ -77,11 +77,12 @@ namespace AdvancedPaste { services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }).Build(); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml index f03a579821..a250fdffdc 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml @@ -11,7 +11,7 @@ - + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml new file mode 100644 index 0000000000..e008d35a9a --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs new file mode 100644 index 0000000000..a28c87ac61 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace AdvancedPaste.Controls +{ + public sealed partial class ClipboardHistoryItemPreviewControl : UserControl + { + public static readonly DependencyProperty ClipboardItemProperty = DependencyProperty.Register( + nameof(ClipboardItem), + typeof(ClipboardItem), + typeof(ClipboardHistoryItemPreviewControl), + new PropertyMetadata(defaultValue: null, OnClipboardItemChanged)); + + public ClipboardItem ClipboardItem + { + get => (ClipboardItem)GetValue(ClipboardItemProperty); + set => SetValue(ClipboardItemProperty, value); + } + + // Computed properties for display + public string Header => ClipboardItem != null ? GetHeaderFromFormat(ClipboardItem.Format) : string.Empty; + + public string IconGlyph => ClipboardItem != null ? GetGlyphFromFormat(ClipboardItem.Format) : string.Empty; + + public string ContentText => ClipboardItem?.Content ?? string.Empty; + + public ImageSource ContentImage => ClipboardItem?.Image; + + public DateTimeOffset? Timestamp => ClipboardItem?.Timestamp ?? ClipboardItem?.Item?.Timestamp; + + public bool HasImage => ContentImage is not null; + + public bool HasText => !string.IsNullOrEmpty(ContentText) && !HasImage; + + public bool HasGlyph => !HasImage && !HasText && !string.IsNullOrEmpty(IconGlyph); + + public ClipboardHistoryItemPreviewControl() + { + InitializeComponent(); + } + + private static void OnClipboardItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is ClipboardHistoryItemPreviewControl control) + { + // Notify bindings that all computed properties may have changed + control.Bindings.Update(); + } + } + + private static string GetHeaderFromFormat(ClipboardFormat format) + { + // Check flags in priority order (most specific first) + if (format.HasFlag(ClipboardFormat.Image)) + { + return GetStringOrFallback("ClipboardPreviewCategoryImage", "Image"); + } + + if (format.HasFlag(ClipboardFormat.Video)) + { + return GetStringOrFallback("ClipboardPreviewCategoryVideo", "Video"); + } + + if (format.HasFlag(ClipboardFormat.Audio)) + { + return GetStringOrFallback("ClipboardPreviewCategoryAudio", "Audio"); + } + + if (format.HasFlag(ClipboardFormat.File)) + { + return GetStringOrFallback("ClipboardPreviewCategoryFile", "File"); + } + + if (format.HasFlag(ClipboardFormat.Text) || format.HasFlag(ClipboardFormat.Html)) + { + return GetStringOrFallback("ClipboardPreviewCategoryText", "Text"); + } + + return GetStringOrFallback("ClipboardPreviewCategoryUnknown", "Clipboard"); + } + + private static string GetGlyphFromFormat(ClipboardFormat format) + { + // Check flags in priority order (most specific first) + if (format.HasFlag(ClipboardFormat.Image)) + { + return "\uEB9F"; // Image icon + } + + if (format.HasFlag(ClipboardFormat.Video)) + { + return "\uE714"; // Video icon + } + + if (format.HasFlag(ClipboardFormat.Audio)) + { + return "\uE189"; // Audio icon + } + + if (format.HasFlag(ClipboardFormat.File)) + { + return "\uE8A5"; // File icon + } + + if (format.HasFlag(ClipboardFormat.Text) || format.HasFlag(ClipboardFormat.Html)) + { + return "\uE8D2"; // Text icon + } + + return "\uE77B"; // Generic clipboard icon + } + + private static string GetStringOrFallback(string resourceKey, string fallback) + { + var value = ResourceLoaderInstance.ResourceLoader.GetString(resourceKey); + return string.IsNullOrEmpty(value) ? fallback : value; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index dd09c717b0..6303564d9b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -7,34 +7,21 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:AdvancedPaste.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:settings="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" + x:Name="PromptBoxControl" mc:Ignorable="d"> - - - #65C8F2 - - - - - - - - #005FB8 - - - - - - - - #48B1E9 - - - + + + + + + + 44 + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png b/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png index 73621edfc0..78a9a18606 100644 Binary files a/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png and b/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png differ diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs index 1ab58bf269..b74192213b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs @@ -18,6 +18,7 @@ namespace AdvancedPaste.Helpers PromptTokens = semanticKernelFormatEvent.PromptTokens; CompletionTokens = semanticKernelFormatEvent.CompletionTokens; ModelName = semanticKernelFormatEvent.ModelName; + ProviderType = semanticKernelFormatEvent.ProviderType; ActionChain = semanticKernelFormatEvent.ActionChain; } @@ -38,6 +39,8 @@ namespace AdvancedPaste.Helpers public string ModelName { get; set; } + public string ProviderType { get; set; } + public string ActionChain { get; set; } public string ToJsonString() => JsonSerializer.Serialize(this, SourceGenerationContext.Default.AIServiceFormatEvent); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs new file mode 100644 index 0000000000..ba7d33f4fb --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using AdvancedPaste.Models; +using Microsoft.SemanticKernel; + +namespace AdvancedPaste.Helpers; + +/// +/// Helper class for extracting AI service usage information from chat messages. +/// +public static class AIServiceUsageHelper +{ + /// + /// Extracts AI service usage information from OpenAI chat message metadata. + /// + /// The chat message containing usage metadata. + /// AI service usage information or AIServiceUsage.None if extraction fails. + public static AIServiceUsage GetOpenAIServiceUsage(ChatMessageContent chatMessage) + { + // Try to get usage information from metadata + if (chatMessage.Metadata?.TryGetValue("Usage", out var usageObj) == true) + { + // Handle different possible usage types through reflection to be version-agnostic + var usageType = usageObj.GetType(); + + try + { + // Try common property names for prompt tokens + var promptTokensProp = usageType.GetProperty("PromptTokens") ?? + usageType.GetProperty("InputTokens") ?? + usageType.GetProperty("InputTokenCount"); + + var completionTokensProp = usageType.GetProperty("CompletionTokens") ?? + usageType.GetProperty("OutputTokens") ?? + usageType.GetProperty("OutputTokenCount"); + + if (promptTokensProp != null && completionTokensProp != null) + { + var promptTokens = (int)(promptTokensProp.GetValue(usageObj) ?? 0); + var completionTokens = (int)(completionTokensProp.GetValue(usageObj) ?? 0); + return new AIServiceUsage(promptTokens, completionTokens); + } + } + catch + { + // If reflection fails, fall back to no usage + } + } + + return AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs new file mode 100644 index 0000000000..9f824d3399 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.ApplicationModel.DataTransfer; + +namespace AdvancedPaste.Helpers +{ + internal static class ClipboardItemHelper + { + /// + /// Creates a ClipboardItem from current clipboard data. + /// + public static async Task CreateFromCurrentClipboardAsync( + DataPackageView clipboardData, + ClipboardFormat availableFormats, + DateTimeOffset? timestamp = null, + BitmapImage existingImage = null) + { + if (clipboardData == null || availableFormats == ClipboardFormat.None) + { + return null; + } + + var clipboardItem = new ClipboardItem + { + Format = availableFormats, + Timestamp = timestamp, + }; + + // Text or HTML content + if (availableFormats.HasFlag(ClipboardFormat.Text) || availableFormats.HasFlag(ClipboardFormat.Html)) + { + clipboardItem.Content = await clipboardData.GetTextOrEmptyAsync(); + } + + // Image content + else if (availableFormats.HasFlag(ClipboardFormat.Image)) + { + // Reuse existing image if provided + if (existingImage != null) + { + clipboardItem.Image = existingImage; + } + else + { + clipboardItem.Image = await TryCreateBitmapImageAsync(clipboardData); + } + } + + return clipboardItem; + } + + /// + /// Creates a BitmapImage from clipboard data. + /// + private static async Task TryCreateBitmapImageAsync(DataPackageView clipboardData) + { + try + { + var imageReference = await clipboardData.GetBitmapAsync(); + if (imageReference != null) + { + using (var imageStream = await imageReference.OpenReadAsync()) + { + var bitmapImage = new BitmapImage(); + await bitmapImage.SetSourceAsync(imageStream); + return bitmapImage; + } + } + } + catch + { + // Silently fail - caller can check for null + } + + return null; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs index 529773f9a6..2cd7554a50 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Models; using ManagedCommon; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.Data.Html; @@ -180,6 +182,46 @@ internal static class DataPackageHelpers } } + internal static async Task GetClipboardTextOrThrowAsync(this DataPackageView dataPackageView, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dataPackageView); + + try + { + if (dataPackageView.Contains(StandardDataFormats.Text)) + { + return await dataPackageView.GetTextAsync(); + } + + if (dataPackageView.Contains(StandardDataFormats.Html)) + { + var html = await dataPackageView.GetHtmlFormatAsync(); + return HtmlUtilities.ConvertToText(html); + } + + if (dataPackageView.Contains(StandardDataFormats.Bitmap)) + { + var bitmap = await dataPackageView.GetImageContentAsync(); + if (bitmap != null) + { + return await OcrHelpers.ExtractTextAsync(bitmap, cancellationToken); + } + } + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + throw CreateClipboardTextMissingException(ex); + } + + throw CreateClipboardTextMissingException(); + } + + private static PasteActionException CreateClipboardTextMissingException(Exception innerException = null) + { + var message = ResourceLoaderInstance.ResourceLoader.GetString("ClipboardEmptyWarning"); + return new PasteActionException(message, innerException ?? new InvalidOperationException("Clipboard does not contain text content.")); + } + internal static async Task GetHtmlContentAsync(this DataPackageView dataPackageView) => dataPackageView.Contains(StandardDataFormats.Html) ? await dataPackageView.GetHtmlFormatAsync() : string.Empty; @@ -195,6 +237,22 @@ internal static class DataPackageHelpers return null; } + internal static async Task GetPreviewBitmapAsync(this DataPackageView dataPackageView) + { + var stream = await dataPackageView.GetImageStreamAsync(); + if (stream == null) + { + return null; + } + + using (stream) + { + var bitmapImage = new BitmapImage(); + bitmapImage.SetSource(stream); + return bitmapImage; + } + } + private static async Task GetImageStreamAsync(this DataPackageView dataPackageView) { if (dataPackageView.Contains(StandardDataFormats.StorageItems)) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs index 105fe2c0d8..d692263dc1 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using AdvancedPaste.Models; using Microsoft.PowerToys.Settings.UI.Library; @@ -12,16 +13,22 @@ namespace AdvancedPaste.Settings { public interface IUserSettings { - public bool IsAdvancedAIEnabled { get; } + public bool IsAIEnabled { get; } public bool ShowCustomPreview { get; } public bool CloseAfterLosingFocus { get; } + public bool EnableClipboardPreview { get; } + public IReadOnlyList CustomActions { get; } public IReadOnlyList AdditionalActions { get; } + public PasteAIConfiguration PasteAIConfiguration { get; } + public event EventHandler Changed; + + Task SetActiveAIProviderAsync(string providerId); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 8a25b70f07..59f31f0e99 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -13,6 +13,7 @@ using AdvancedPaste.Models; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Utilities; +using Windows.Security.Credentials; namespace AdvancedPaste.Settings { @@ -33,23 +34,29 @@ namespace AdvancedPaste.Settings public event EventHandler Changed; - public bool IsAdvancedAIEnabled { get; private set; } + public bool IsAIEnabled { get; private set; } public bool ShowCustomPreview { get; private set; } public bool CloseAfterLosingFocus { get; private set; } + public bool EnableClipboardPreview { get; private set; } + public IReadOnlyList AdditionalActions => _additionalActions; public IReadOnlyList CustomActions => _customActions; + public PasteAIConfiguration PasteAIConfiguration { get; private set; } + public UserSettings(IFileSystem fileSystem) { _settingsUtils = new SettingsUtils(fileSystem); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + EnableClipboardPreview = true; + PasteAIConfiguration = new PasteAIConfiguration(); _additionalActions = []; _customActions = []; _taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); @@ -94,13 +101,17 @@ namespace AdvancedPaste.Settings var settings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteModuleName); if (settings != null) { + bool migratedLegacyEnablement = TryMigrateLegacyAIEnablement(settings); + void UpdateSettings() { var properties = settings.Properties; - IsAdvancedAIEnabled = properties.IsAdvancedAIEnabled; + IsAIEnabled = properties.IsAIEnabled; ShowCustomPreview = properties.ShowCustomPreview; CloseAfterLosingFocus = properties.CloseAfterLosingFocus; + EnableClipboardPreview = properties.EnableClipboardPreview; + PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration(); var sourceAdditionalActions = properties.AdditionalActions; (PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats = @@ -126,6 +137,11 @@ namespace AdvancedPaste.Settings Task.Factory .StartNew(UpdateSettings, CancellationToken.None, TaskCreationOptions.None, _taskScheduler) .Wait(); + + if (migratedLegacyEnablement) + { + settings.Save(_settingsUtils); + } } retry = false; @@ -144,6 +160,220 @@ namespace AdvancedPaste.Settings } } + private static bool TryMigrateLegacyAIEnablement(AdvancedPasteSettings settings) + { + if (settings?.Properties is null) + { + return false; + } + + var properties = settings.Properties; + bool legacyAdvancedAIConsumed = properties.TryConsumeLegacyAdvancedAIEnabled(out var advancedFlag); + bool legacyAdvancedAIEnabled = legacyAdvancedAIConsumed && advancedFlag; + PasswordCredential legacyCredential = TryGetLegacyOpenAICredential(); + + if (legacyCredential is null) + { + return legacyAdvancedAIConsumed; + } + + var configuration = properties.PasteAIConfiguration; + + if (configuration is null) + { + configuration = new PasteAIConfiguration(); + properties.PasteAIConfiguration = configuration; + } + + bool configurationUpdated = false; + + var ensureResult = AdvancedPasteMigrationHelper.EnsureOpenAIProvider(configuration); + PasteAIProviderDefinition openAIProvider = ensureResult.Provider; + configurationUpdated |= ensureResult.Updated; + + if (legacyAdvancedAIConsumed && openAIProvider is not null && openAIProvider.EnableAdvancedAI != legacyAdvancedAIEnabled) + { + openAIProvider.EnableAdvancedAI = legacyAdvancedAIEnabled; + configurationUpdated = true; + } + + if (openAIProvider is not null) + { + StoreMigratedOpenAICredential(openAIProvider.Id, openAIProvider.ServiceType, legacyCredential.Password); + RemoveLegacyOpenAICredential(); + } + + const bool shouldEnableAI = true; + bool enabledUpdated = false; + if (properties.IsAIEnabled != shouldEnableAI) + { + properties.IsAIEnabled = shouldEnableAI; + enabledUpdated = true; + } + + return configurationUpdated || enabledUpdated || legacyAdvancedAIConsumed; + } + + private static PasswordCredential TryGetLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + var credential = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + credential?.RetrievePassword(); + return credential; + } + catch (Exception) + { + return null; + } + } + + private static void RemoveLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + TryRemoveCredential(vault, "https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + } + catch (Exception) + { + } + } + + private static void StoreMigratedOpenAICredential(string providerId, string serviceType, string password) + { + if (string.IsNullOrWhiteSpace(password)) + { + return; + } + + try + { + var serviceKind = serviceType.ToAIServiceType(); + if (serviceKind != AIServiceType.OpenAI) + { + return; + } + + string resource = "https://platform.openai.com/api-keys"; + string username = $"PowerToys_AdvancedPaste_PasteAI_openai_{NormalizeProviderIdentifier(providerId)}"; + + PasswordVault vault = new(); + TryRemoveCredential(vault, resource, username); + + PasswordCredential credential = new(resource, username, password); + vault.Add(credential); + } + catch (Exception ex) + { + Logger.LogError("Failed to migrate legacy OpenAI credential", ex); + } + } + + private static void TryRemoveCredential(PasswordVault vault, string credentialResource, string credentialUserName) + { + try + { + PasswordCredential existingCred = vault.Retrieve(credentialResource, credentialUserName); + vault.Remove(existingCred); + } + catch (Exception) + { + // Credential doesn't exist, which is fine + } + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } + + public async Task SetActiveAIProviderAsync(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return; + } + + await Task.Run(() => + { + lock (_loadingSettingsLock) + { + var settings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteModuleName); + var configuration = settings?.Properties?.PasteAIConfiguration; + var providers = configuration?.Providers; + + if (configuration == null || providers == null || providers.Count == 0) + { + return; + } + + var target = providers.FirstOrDefault(provider => string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase)); + if (target == null) + { + return; + } + + if (string.Equals(configuration.ActiveProvider?.Id, providerId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + configuration.ActiveProviderId = providerId; + + foreach (var provider in providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + + try + { + settings.Save(_settingsUtils); + } + catch (Exception ex) + { + Logger.LogError("Failed to set active AI provider", ex); + return; + } + + try + { + Task.Factory + .StartNew( + () => + { + PasteAIConfiguration.ActiveProviderId = providerId; + + if (PasteAIConfiguration.Providers is not null) + { + foreach (var provider in PasteAIConfiguration.Providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + } + + Changed?.Invoke(this, EventArgs.Empty); + }, + CancellationToken.None, + TaskCreationOptions.None, + _taskScheduler) + .Wait(); + } + catch (Exception ex) + { + Logger.LogError("Failed to dispatch active AI provider change", ex); + } + } + }); + } + public void Dispose() { Dispose(true); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs index 1013108bc9..16814e7001 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using AdvancedPaste.Helpers; using Microsoft.UI.Xaml.Media.Imaging; using Windows.ApplicationModel.DataTransfer; @@ -12,10 +13,15 @@ public class ClipboardItem { public string Content { get; set; } - public ClipboardHistoryItem Item { get; set; } - public BitmapImage Image { get; set; } + public ClipboardFormat Format { get; set; } + + public DateTimeOffset? Timestamp { get; set; } + + // Only used for clipboard history items that have a ClipboardHistoryItem + public ClipboardHistoryItem Item { get; set; } + public string Description => !string.IsNullOrEmpty(Content) ? Content : Image is not null ? ResourceLoaderInstance.ResourceLoader.GetString("ClipboardHistoryImage") : string.Empty; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs new file mode 100644 index 0000000000..c886bcef43 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services; + +public sealed class AdvancedAIKernelService : KernelServiceBase +{ + private sealed record RuntimeConfiguration( + AIServiceType ServiceType, + string ModelName, + string Endpoint, + string DeploymentName, + string ModelPath, + string SystemPrompt, + bool ModerationEnabled) : IKernelRuntimeConfiguration; + + private readonly IAICredentialsProvider credentialsProvider; + + public AdvancedAIKernelService( + IAICredentialsProvider credentialsProvider, + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) + : base(queryCacheService, promptModerationService, userSettings, customActionTransformService) + { + ArgumentNullException.ThrowIfNull(credentialsProvider); + + this.credentialsProvider = credentialsProvider; + } + + protected override string AdvancedAIModelName => GetRuntimeConfiguration().ModelName; + + protected override PromptExecutionSettings PromptExecutionSettings => CreatePromptExecutionSettings(); + + protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) + { + ArgumentNullException.ThrowIfNull(kernelBuilder); + + var runtimeConfig = GetRuntimeConfiguration(); + var serviceType = runtimeConfig.ServiceType; + var modelName = runtimeConfig.ModelName; + var requiresApiKey = RequiresApiKey(serviceType); + var apiKey = string.Empty; + if (requiresApiKey) + { + this.credentialsProvider.Refresh(); + apiKey = (this.credentialsProvider.GetKey() ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"An API key is required for {serviceType} but none was found in the credential vault."); + } + } + + var endpoint = string.IsNullOrWhiteSpace(runtimeConfig.Endpoint) ? null : runtimeConfig.Endpoint.Trim(); + var deployment = string.IsNullOrWhiteSpace(runtimeConfig.DeploymentName) ? modelName : runtimeConfig.DeploymentName; + + switch (serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(modelName, apiKey, serviceId: modelName); + break; + case AIServiceType.AzureOpenAI: + kernelBuilder.AddAzureOpenAIChatCompletion(deployment, RequireEndpoint(endpoint, serviceType), apiKey, serviceId: modelName); + break; + default: + throw new NotSupportedException($"Service type '{runtimeConfig.ServiceType}' is not supported"); + } + } + + protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) + { + return AIServiceUsageHelper.GetOpenAIServiceUsage(chatMessage); + } + + protected override bool ShouldModerateAdvancedAI() + { + if (!TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return false; + } + + return runtimeConfig.ModerationEnabled && (runtimeConfig.ServiceType == AIServiceType.OpenAI || runtimeConfig.ServiceType == AIServiceType.AzureOpenAI); + } + + private static string GetModelName(PasteAIProviderDefinition config) + { + if (!string.IsNullOrWhiteSpace(config?.ModelName)) + { + return config.ModelName; + } + + return "gpt-4o"; + } + + protected override IKernelRuntimeConfiguration GetRuntimeConfiguration() + { + if (TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return runtimeConfig; + } + + throw new InvalidOperationException("No Advanced AI provider is configured."); + } + + private bool TryGetRuntimeConfiguration(out IKernelRuntimeConfiguration runtimeConfig) + { + runtimeConfig = null; + + if (!TryResolveAdvancedProvider(out var provider)) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + if (!IsServiceTypeSupported(serviceType)) + { + return false; + } + + runtimeConfig = new RuntimeConfiguration( + serviceType, + GetModelName(provider), + provider.EndpointUrl, + provider.DeploymentName, + provider.ModelPath, + provider.SystemPrompt, + provider.ModerationEnabled); + return true; + } + + private bool TryResolveAdvancedProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = this.UserSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedProvider(PasteAIProviderDefinition provider) + { + if (provider is null || !provider.EnableAdvancedAI) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + return IsServiceTypeSupported(serviceType); + } + + private static bool IsServiceTypeSupported(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI or AIServiceType.AzureOpenAI; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return true; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} configuration but was not provided."); + } + + private PromptExecutionSettings CreatePromptExecutionSettings() + { + var serviceType = GetRuntimeConfiguration().ServiceType; + return new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(), + }; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs new file mode 100644 index 0000000000..562ea3976c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformResult + { + public CustomActionTransformResult(string content, AIServiceUsage usage) + { + Content = content; + Usage = usage; + } + + public string Content { get; } + + public AIServiceUsage Usage { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs new file mode 100644 index 0000000000..57d55492a4 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using AdvancedPaste.Telemetry; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformService : ICustomActionTransformService + { + private const string DefaultSystemPrompt = """ + You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. + Do not output anything else besides the reformatted clipboard content. + """; + + private readonly IPromptModerationService promptModerationService; + private readonly IPasteAIProviderFactory providerFactory; + private readonly IAICredentialsProvider credentialsProvider; + private readonly IUserSettings userSettings; + + public CustomActionTransformService(IPromptModerationService promptModerationService, IPasteAIProviderFactory providerFactory, IAICredentialsProvider credentialsProvider, IUserSettings userSettings) + { + this.promptModerationService = promptModerationService; + this.providerFactory = providerFactory; + this.credentialsProvider = credentialsProvider; + this.userSettings = userSettings; + } + + public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) + { + var pasteConfig = userSettings?.PasteAIConfiguration; + var providerConfig = BuildProviderConfig(pasteConfig); + + return await TransformAsync(prompt, inputText, providerConfig, cancellationToken, progress); + } + + private async Task TransformAsync(string prompt, string inputText, PasteAIConfig providerConfig, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(providerConfig); + + if (string.IsNullOrWhiteSpace(prompt)) + { + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + if (string.IsNullOrWhiteSpace(inputText)) + { + Logger.LogWarning("Clipboard has no usable text data"); + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + var systemPrompt = providerConfig.SystemPrompt ?? DefaultSystemPrompt; + + var fullPrompt = (systemPrompt ?? string.Empty) + "\n\n" + (inputText ?? string.Empty); + + if (ShouldModerate(providerConfig)) + { + await promptModerationService.ValidateAsync(fullPrompt, cancellationToken); + } + + try + { + var provider = providerFactory.CreateProvider(providerConfig); + + var request = new PasteAIRequest + { + Prompt = prompt, + InputText = inputText, + SystemPrompt = systemPrompt, + }; + + var operationStart = DateTime.UtcNow; + + var providerContent = await provider.ProcessPasteAsync( + request, + cancellationToken, + progress); + + var durationMs = (int)Math.Round((DateTime.UtcNow - operationStart).TotalMilliseconds); + + var usage = request.Usage; + var content = providerContent ?? string.Empty; + + // Log endpoint usage (custom action pipeline is not the advanced SK flow) + var endpointEvent = new AdvancedPasteEndpointUsageEvent(providerConfig.ProviderType, providerConfig.Model ?? string.Empty, isAdvanced: false, durationMs: durationMs); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + + Logger.LogDebug($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} complete; ModelName={providerConfig.Model ?? string.Empty}, PromptTokens={usage.PromptTokens}, CompletionTokens={usage.CompletionTokens}, DurationMs={durationMs}"); + + return new CustomActionTransformResult(content, usage); + } + catch (Exception ex) + { + Logger.LogError($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} failed", ex); + var statusCode = ExtractStatusCode(ex); + var modelName = providerConfig.Model ?? string.Empty; + AdvancedPasteCustomActionErrorEvent errorEvent = new(providerConfig.ProviderType, modelName, statusCode, ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); + PowerToysTelemetry.Log.WriteEvent(errorEvent); + + if (ex is PasteActionException or OperationCanceledException) + { + throw; + } + + var failureMessage = providerConfig.ProviderType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => ErrorHelpers.TranslateErrorText(statusCode), + _ => ResourceLoaderInstance.ResourceLoader.GetString("PasteError"), + }; + + throw new PasteActionException(failureMessage, ex); + } + } + + private static int ExtractStatusCode(Exception exception) + { + if (exception is HttpOperationException httpOperationException) + { + return (int?)httpOperationException.StatusCode ?? -1; + } + + if (exception is HttpRequestException httpRequestException && httpRequestException.StatusCode is HttpStatusCode statusCode) + { + return (int)statusCode; + } + + return -1; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config) + { + config ??= new PasteAIConfiguration(); + var provider = config.ActiveProvider ?? config.Providers?.FirstOrDefault() ?? new PasteAIProviderDefinition(); + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + var systemPrompt = string.IsNullOrWhiteSpace(provider.SystemPrompt) ? DefaultSystemPrompt : provider.SystemPrompt; + var apiKey = AcquireApiKey(serviceType); + var modelName = provider.ModelName; + + var providerConfig = new PasteAIConfig + { + ProviderType = serviceType, + ApiKey = apiKey, + Model = modelName, + Endpoint = provider.EndpointUrl, + DeploymentName = provider.DeploymentName, + LocalModelPath = provider.ModelPath, + ModelPath = provider.ModelPath, + SystemPrompt = systemPrompt, + ModerationEnabled = provider.ModerationEnabled, + }; + + return providerConfig; + } + + private string AcquireApiKey(AIServiceType serviceType) + { + if (!RequiresApiKey(serviceType)) + { + return string.Empty; + } + + credentialsProvider.Refresh(); + return credentialsProvider.GetKey() ?? string.Empty; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Onnx => false, + AIServiceType.Ollama => false, + _ => true, + }; + } + + private static bool ShouldModerate(PasteAIConfig providerConfig) + { + if (providerConfig is null || !providerConfig.ModerationEnabled) + { + return false; + } + + return providerConfig.ProviderType == AIServiceType.OpenAI || providerConfig.ProviderType == AIServiceType.AzureOpenAI; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs new file mode 100644 index 0000000000..8b57baae74 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using LanguageModelProvider; +using Microsoft.Extensions.AI; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions; + +public sealed class FoundryLocalPasteProvider : IPasteAIProvider +{ + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.FoundryLocal, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new FoundryLocalPasteProvider(config)); + + private static readonly FoundryLocalModelProvider _modelProvider = FoundryLocalModelProvider.Instance; + + private readonly PasteAIConfig _config; + + public FoundryLocalPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + } + + public async Task IsAvailableAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return await FoundryLocalModelProvider.Instance.IsAvailable().ConfigureAwait(false); + } + + public async Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new PasteActionException( + "System prompt is required for Foundry Local", + new ArgumentException("System prompt must be provided", nameof(request))); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new PasteActionException( + "Prompt and input text are required", + new ArgumentException("Prompt and input text must be provided", nameof(request))); + } + + var modelReference = _config?.Model; + if (string.IsNullOrWhiteSpace(modelReference)) + { + throw new PasteActionException( + "No Foundry Local model selected", + new InvalidOperationException("Model identifier is required"), + aiServiceMessage: "Please select a model in the AI provider settings."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + IChatClient chatClient; + try + { + chatClient = _modelProvider.GetIChatClient(modelReference); + } + catch (InvalidOperationException ex) + { + // GetIChatClient throws InvalidOperationException for user-facing errors + var errorMessage = string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("FoundryLocal_UnableToLoadModel"), modelReference); + throw new PasteActionException( + errorMessage, + ex, + aiServiceMessage: ex.Message); + } + + var userMessageContent = $""" + User instructions: + {prompt} + + Text: + {inputText} + + Output: + """; + + var chatMessages = new List + { + new(ChatRole.System, systemPrompt), + new(ChatRole.User, userMessageContent), + }; + + var chatOptions = CreateChatOptions(_config?.SystemPrompt, modelReference); + + progress?.Report(0.1); + + var response = await chatClient.GetResponseAsync(chatMessages, chatOptions, cancellationToken).ConfigureAwait(false); + + progress?.Report(0.8); + + var responseText = GetResponseText(response); + request.Usage = ToUsage(response.Usage); + + progress?.Report(1.0); + + return responseText ?? string.Empty; + } + catch (OperationCanceledException) + { + // Let cancellation exceptions pass through unchanged + throw; + } + catch (PasteActionException) + { + // Let our custom exceptions pass through unchanged + throw; + } + catch (Exception ex) + { + // Wrap any other exceptions with context + var modelInfo = !string.IsNullOrWhiteSpace(_config?.Model) ? $" (Model: {_config.Model})" : string.Empty; + throw new PasteActionException( + $"Failed to generate response using Foundry Local{modelInfo}", + ex, + aiServiceMessage: $"Error details: {ex.Message}"); + } + } + + private static ChatOptions CreateChatOptions(string systemPrompt, string modelReference) + { + var options = new ChatOptions + { + ModelId = modelReference, + MaxOutputTokens = 2048, + }; + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + options.Instructions = systemPrompt; + } + + return options; + } + + private static string GetResponseText(ChatResponse response) + { + if (!string.IsNullOrWhiteSpace(response.Text)) + { + return response.Text; + } + + if (response.Messages is { Count: > 0 }) + { + var lastMessage = response.Messages.LastOrDefault(m => !string.IsNullOrWhiteSpace(m.Text)); + if (!string.IsNullOrWhiteSpace(lastMessage?.Text)) + { + return lastMessage.Text; + } + } + + return string.Empty; + } + + private static AIServiceUsage ToUsage(UsageDetails usageDetails) + { + if (usageDetails is null) + { + return AIServiceUsage.None; + } + + int promptTokens = (int)(usageDetails.InputTokenCount ?? 0); + int completionTokens = (int)(usageDetails.OutputTokenCount ?? 0); + + if (promptTokens == 0 && completionTokens == 0) + { + return AIServiceUsage.None; + } + + return new AIServiceUsage(promptTokens, completionTokens); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs new file mode 100644 index 0000000000..1c3ecb980c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +using AdvancedPaste.Settings; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface ICustomActionTransformService + { + Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs new file mode 100644 index 0000000000..764d99f942 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProvider + { + Task IsAvailableAsync(CancellationToken cancellationToken); + + Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs new file mode 100644 index 0000000000..aacc61bec9 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProviderFactory + { + IPasteAIProvider CreateProvider(PasteAIConfig config); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs new file mode 100644 index 0000000000..f4d45ccd74 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class LocalModelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.Onnx, + AIServiceType.ML, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new LocalModelPasteProvider(config)); + + private readonly PasteAIConfig _config; + + public LocalModelPasteProvider(PasteAIConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + public Task IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + // TODO: Implement local model inference logic using _config.LocalModelPath/_config.ModelPath + var content = request.InputText ?? string.Empty; + request.Usage = AIServiceUsage.None; + return Task.FromResult(content); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs new file mode 100644 index 0000000000..1d8a60f041 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace AdvancedPaste.Services.CustomActions +{ + public class PasteAIConfig + { + public AIServiceType ProviderType { get; set; } + + public string Model { get; set; } + + public string ApiKey { get; set; } + + public string Endpoint { get; set; } + + public string DeploymentName { get; set; } + + public string LocalModelPath { get; set; } + + public string ModelPath { get; set; } + + public string SystemPrompt { get; set; } + + public bool ModerationEnabled { get; set; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs new file mode 100644 index 0000000000..7339b4e4e3 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderFactory : IPasteAIProviderFactory + { + private static readonly IReadOnlyList ProviderRegistrations = new[] + { + SemanticKernelPasteProvider.Registration, + LocalModelPasteProvider.Registration, + FoundryLocalPasteProvider.Registration, + }; + + private static readonly IReadOnlyDictionary> ProviderFactories = CreateProviderFactories(); + + public IPasteAIProvider CreateProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + var serviceType = config.ProviderType; + if (serviceType == AIServiceType.Unknown) + { + serviceType = AIServiceType.OpenAI; + config.ProviderType = serviceType; + } + + if (!ProviderFactories.TryGetValue(serviceType, out var factory)) + { + throw new NotSupportedException($"Provider {config.ProviderType} not supported"); + } + + return factory(config); + } + + private static IReadOnlyDictionary> CreateProviderFactories() + { + var map = new Dictionary>(); + + foreach (var registration in ProviderRegistrations) + { + Register(map, registration.SupportedTypes, registration.Factory); + } + + return map; + } + + private static void Register(Dictionary> map, IReadOnlyCollection types, Func factory) + { + foreach (var type in types) + { + map[type] = factory; + } + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs new file mode 100644 index 0000000000..6bd78450e8 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderRegistration + { + public PasteAIProviderRegistration(IReadOnlyCollection supportedTypes, Func factory) + { + SupportedTypes = supportedTypes ?? throw new ArgumentNullException(nameof(supportedTypes)); + Factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public IReadOnlyCollection SupportedTypes { get; } + + public Func Factory { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs new file mode 100644 index 0000000000..0e15c93e05 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIRequest + { + public string Prompt { get; init; } + + public string InputText { get; init; } + + public string SystemPrompt { get; init; } + + public AIServiceUsage Usage { get; set; } = AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs new file mode 100644 index 0000000000..eb2f56e01f --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureAIInference; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.MistralAI; +using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class SemanticKernelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.OpenAI, + AIServiceType.AzureOpenAI, + AIServiceType.Mistral, + AIServiceType.Google, + AIServiceType.AzureAIInference, + AIServiceType.Ollama, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new SemanticKernelPasteProvider(config)); + + private readonly PasteAIConfig _config; + private readonly AIServiceType _serviceType; + + public SemanticKernelPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + _serviceType = config.ProviderType; + if (_serviceType == AIServiceType.Unknown) + { + _serviceType = AIServiceType.OpenAI; + _config.ProviderType = _serviceType; + } + } + + public IReadOnlyCollection SupportedServiceTypes => SupportedTypes; + + public Task IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public async Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new ArgumentException("System prompt must be provided", nameof(request)); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new ArgumentException("Prompt and input text must be provided", nameof(request)); + } + + var userMessageContent = $""" + User instructions: + {prompt} + + Clipboard Content: + {inputText} + + Output: + """; + + var executionSettings = CreateExecutionSettings(); + var kernel = CreateKernel(); + var modelId = _config.Model; + + IChatCompletionService chatService; + if (!string.IsNullOrWhiteSpace(modelId)) + { + try + { + chatService = kernel.GetRequiredService(modelId); + } + catch (Exception) + { + chatService = kernel.GetRequiredService(); + } + } + else + { + chatService = kernel.GetRequiredService(); + } + + var chatHistory = new ChatHistory(); + chatHistory.AddSystemMessage(systemPrompt); + chatHistory.AddUserMessage(userMessageContent); + + var response = await chatService.GetChatMessageContentAsync(chatHistory, executionSettings, kernel, cancellationToken); + chatHistory.Add(response); + + request.Usage = AIServiceUsageHelper.GetOpenAIServiceUsage(response); + return response.Content; + } + + private Kernel CreateKernel() + { + var kernelBuilder = Kernel.CreateBuilder(); + var endpoint = string.IsNullOrWhiteSpace(_config.Endpoint) ? null : _config.Endpoint.Trim(); + var apiKey = _config.ApiKey?.Trim() ?? string.Empty; + + if (RequiresApiKey(_serviceType) && string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"API key is required for {_serviceType} but was not provided."); + } + + switch (_serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(_config.Model, apiKey, serviceId: _config.Model); + break; + case AIServiceType.AzureOpenAI: + var deploymentName = string.IsNullOrWhiteSpace(_config.DeploymentName) ? _config.Model : _config.DeploymentName; + kernelBuilder.AddAzureOpenAIChatCompletion(deploymentName, RequireEndpoint(endpoint, _serviceType), apiKey, serviceId: _config.Model); + break; + case AIServiceType.Mistral: + kernelBuilder.AddMistralChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.Google: + kernelBuilder.AddGoogleAIGeminiChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.AzureAIInference: + kernelBuilder.AddAzureAIInferenceChatCompletion(_config.Model, apiKey: apiKey, endpoint: new Uri(endpoint)); + break; + case AIServiceType.Ollama: + kernelBuilder.AddOllamaChatCompletion(_config.Model, endpoint: new Uri(endpoint)); + break; + + default: + throw new NotSupportedException($"Provider '{_config.ProviderType}' is not supported by {nameof(SemanticKernelPasteProvider)}"); + } + + return kernelBuilder.Build(); + } + + private PromptExecutionSettings CreateExecutionSettings() + { + return _serviceType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = null, + }, + _ => new PromptExecutionSettings(), + }; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Ollama => false, + _ => true, + }; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} but was not provided."); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs new file mode 100644 index 0000000000..648881fba0 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Threading; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Windows.Security.Credentials; + +namespace AdvancedPaste.Services; + +/// +/// Enhanced credentials provider that supports different AI service types +/// Keys are stored in Windows Credential Vault with service-specific identifiers +/// +public sealed class EnhancedVaultCredentialsProvider : IAICredentialsProvider +{ + private sealed class CredentialSlot + { + public AIServiceType ServiceType { get; set; } = AIServiceType.Unknown; + + public string ProviderId { get; set; } = string.Empty; + + public (string Resource, string Username)? Entry { get; set; } + + public string Key { get; set; } = string.Empty; + } + + private readonly IUserSettings _userSettings; + private readonly CredentialSlot _slot; + private readonly Lock _syncRoot = new(); + + public EnhancedVaultCredentialsProvider(IUserSettings userSettings) + { + _userSettings = userSettings ?? throw new ArgumentNullException(nameof(userSettings)); + + _slot = new CredentialSlot(); + + Refresh(); + } + + public string GetKey() + { + using (_syncRoot.EnterScope()) + { + UpdateSlot(forceRefresh: false); + return _slot.Key; + } + } + + public bool IsConfigured() + { + return !string.IsNullOrEmpty(GetKey()); + } + + public bool Refresh() + { + using (_syncRoot.EnterScope()) + { + return UpdateSlot(forceRefresh: true); + } + } + + private bool UpdateSlot(bool forceRefresh) + { + var (serviceType, providerId) = ResolveCredentialTarget(); + var desiredServiceType = NormalizeServiceType(serviceType); + providerId ??= string.Empty; + + var hasChanged = false; + + if (_slot.ServiceType != desiredServiceType || !string.Equals(_slot.ProviderId, providerId, StringComparison.Ordinal)) + { + _slot.ServiceType = desiredServiceType; + _slot.ProviderId = providerId; + _slot.Entry = BuildCredentialEntry(desiredServiceType, providerId); + forceRefresh = true; + hasChanged = true; + } + + if (!forceRefresh) + { + return hasChanged; + } + + var newKey = LoadKey(_slot.Entry); + if (!string.Equals(_slot.Key, newKey, StringComparison.Ordinal)) + { + _slot.Key = newKey; + hasChanged = true; + } + + return hasChanged; + } + + private (AIServiceType ServiceType, string ProviderId) ResolveCredentialTarget() + { + var provider = _userSettings.PasteAIConfiguration?.ActiveProvider; + if (provider is null) + { + return (AIServiceType.OpenAI, string.Empty); + } + + return (provider.ServiceTypeKind, provider.Id ?? string.Empty); + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static string LoadKey((string Resource, string Username)? entry) + { + if (entry is null) + { + return string.Empty; + } + + try + { + var credential = new PasswordVault().Retrieve(entry.Value.Resource, entry.Value.Username); + return credential?.Password ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + + private static (string Resource, string Username)? BuildCredentialEntry(AIServiceType serviceType, string providerId) + { + string resource; + string serviceKey; + + switch (serviceType) + { + case AIServiceType.OpenAI: + resource = "https://platform.openai.com/api-keys"; + serviceKey = "openai"; + break; + case AIServiceType.AzureOpenAI: + resource = "https://azure.microsoft.com/products/ai-services/openai-service"; + serviceKey = "azureopenai"; + break; + case AIServiceType.AzureAIInference: + resource = "https://azure.microsoft.com/products/ai-services/ai-inference"; + serviceKey = "azureaiinference"; + break; + case AIServiceType.Mistral: + resource = "https://console.mistral.ai/account/api-keys"; + serviceKey = "mistral"; + break; + case AIServiceType.Google: + resource = "https://ai.google.dev/"; + serviceKey = "google"; + break; + case AIServiceType.FoundryLocal: + case AIServiceType.ML: + case AIServiceType.Onnx: + case AIServiceType.Ollama: + return null; + default: + return null; + } + + string username = $"PowerToys_AdvancedPaste_PasteAI_{serviceKey}_{NormalizeProviderIdentifier(providerId)}"; + return (resource, username); + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs index 54759b7dc8..7aa6f63b19 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs @@ -4,11 +4,26 @@ namespace AdvancedPaste.Services; +/// +/// Provides access to AI credentials stored for Advanced Paste scenarios. +/// public interface IAICredentialsProvider { - bool IsConfigured { get; } + /// + /// Gets a value indicating whether any credential is configured. + /// + /// when a non-empty credential exists for the active AI provider. + bool IsConfigured(); - string Key { get; } + /// + /// Retrieves the credential for the active AI provider. + /// + /// Credential string or when missing. + string GetKey(); + /// + /// Refreshes the cached credential for the active AI provider. + /// + /// when the credential changed. bool Refresh(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs deleted file mode 100644 index 75f1df259e..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace AdvancedPaste.Services; - -public interface ICustomTextTransformService -{ - Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs new file mode 100644 index 0000000000..d634c13e30 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services; + +/// +/// Represents runtime information required to configure an AI kernel service. +/// +public interface IKernelRuntimeConfiguration +{ + AIServiceType ServiceType { get; } + + string ModelName { get; } + + string Endpoint { get; } + + string DeploymentName { get; } + + string ModelPath { get; } + + string SystemPrompt { get; } + + bool ModerationEnabled { get; } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs index e921b21e54..47e208eb49 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs @@ -5,15 +5,16 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Models.KernelQueryCache; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; using AdvancedPaste.Telemetry; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; @@ -21,15 +22,21 @@ using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheService, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : IKernelService +public abstract class KernelServiceBase( + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) : IKernelService { private const string PromptParameterName = "prompt"; + private const string DefaultSystemPrompt = "You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. Call function when necessary to help user finish the transformation task. You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. The user will put in a request to format their clipboard data and you will fulfill it. Do not output anything else besides the reformatted clipboard content."; private readonly IKernelQueryCacheService _queryCacheService = queryCacheService; private readonly IPromptModerationService _promptModerationService = promptModerationService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly IUserSettings _userSettings = userSettings; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; - protected abstract string ModelName { get; } + protected abstract string AdvancedAIModelName { get; } protected abstract PromptExecutionSettings PromptExecutionSettings { get; } @@ -37,6 +44,8 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi protected abstract AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage); + protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration(); + public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress) { Logger.LogTrace(); @@ -132,21 +141,21 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt, CancellationToken cancellationToken) { + var runtimeConfig = GetRuntimeConfiguration(); + ChatHistory chatHistory = []; - chatHistory.AddSystemMessage(""" - You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. - You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. - The user will put in a request to format their clipboard data and you will fulfill it. - You will not directly see the output clipboard content, and do not need to provide it in the chat. You just need to do the transform operations as needed. - If you are unable to fulfill the request, end with an error message in the language of the user's request. - """); + var systemPrompt = string.IsNullOrWhiteSpace(runtimeConfig.SystemPrompt) ? DefaultSystemPrompt : runtimeConfig.SystemPrompt; + chatHistory.AddSystemMessage(systemPrompt); chatHistory.AddSystemMessage($"Available clipboard formats: {await kernel.GetDataFormatsAsync()}"); chatHistory.AddUserMessage(prompt); - await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + if (ShouldModerateAdvancedAI()) + { + await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + } - var chatResult = await kernel.GetRequiredService() + var chatResult = await kernel.GetRequiredService(AdvancedAIModelName) .GetChatMessageContentAsync(chatHistory, PromptExecutionSettings, kernel, cancellationToken); chatHistory.Add(chatResult); @@ -175,10 +184,26 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return ([], AIServiceUsage.None); } + protected IUserSettings UserSettings => _userSettings; + private void LogResult(bool cacheUsed, bool isSavedQuery, IEnumerable actionChain, AIServiceUsage usage) { - AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new(cacheUsed, isSavedQuery, usage.PromptTokens, usage.CompletionTokens, ModelName, AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); + var runtimeConfig = GetRuntimeConfiguration(); + + AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new( + cacheUsed, + isSavedQuery, + usage.PromptTokens, + usage.CompletionTokens, + AdvancedAIModelName, + runtimeConfig.ServiceType.ToString(), + AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); PowerToysTelemetry.Log.WriteEvent(telemetryEvent); + + // Log endpoint usage + var endpointEvent = new AdvancedPasteEndpointUsageEvent(runtimeConfig.ServiceType, AdvancedAIModelName, isAdvanced: true); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + var logEvent = new AIServiceFormatEvent(telemetryEvent); Logger.LogDebug($"{nameof(TransformClipboardAsync)} complete; {logEvent.ToJsonString()}"); } @@ -191,20 +216,96 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return kernelBuilder.Build(); } - private IEnumerable GetKernelFunctions() => - from format in Enum.GetValues() - let metadata = PasteFormat.MetadataDict[format] - let coreDescription = metadata.KernelFunctionDescription - where !string.IsNullOrEmpty(coreDescription) - let requiresPrompt = metadata.RequiresPrompt - orderby requiresPrompt descending - select KernelFunctionFactory.CreateFromMethod( - method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) - : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), - functionName: format.ToString(), - description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", - parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, - returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + private IEnumerable GetKernelFunctions() + { + // Get standard format functions + var standardFunctions = + from format in Enum.GetValues() + let metadata = PasteFormat.MetadataDict[format] + let coreDescription = metadata.KernelFunctionDescription + where !string.IsNullOrEmpty(coreDescription) + let requiresPrompt = metadata.RequiresPrompt + orderby requiresPrompt descending + select KernelFunctionFactory.CreateFromMethod( + method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) + : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), + functionName: format.ToString(), + description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", + parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + + HashSet usedFunctionNames = new(Enum.GetNames(), StringComparer.OrdinalIgnoreCase); + + // Get custom action functions + var customActionFunctions = _userSettings.CustomActions + .Where(customAction => !string.IsNullOrWhiteSpace(customAction.Name) && !string.IsNullOrWhiteSpace(customAction.Prompt)) + .Select(customAction => + { + var sanitizedBaseName = SanitizeFunctionName(customAction.Name); + var functionName = GetUniqueFunctionName(sanitizedBaseName, usedFunctionNames, customAction.Id); + var description = string.IsNullOrWhiteSpace(customAction.Description) + ? $"Runs the \"{customAction.Name}\" custom action." + : customAction.Description; + return KernelFunctionFactory.CreateFromMethod( + method: async (Kernel kernel) => await ExecuteCustomActionAsync(kernel, customAction.Prompt), + functionName: functionName, + description: description, + parameters: null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + }); + + return standardFunctions.Concat(customActionFunctions); + } + + private static string GetUniqueFunctionName(string baseName, HashSet usedFunctionNames, int customActionId) + { + ArgumentNullException.ThrowIfNull(usedFunctionNames); + + var candidate = string.IsNullOrEmpty(baseName) ? "_CustomAction" : baseName; + + if (usedFunctionNames.Add(candidate)) + { + return candidate; + } + + int suffix = 1; + while (true) + { + var nextCandidate = $"{candidate}_{customActionId}_{suffix}"; + if (usedFunctionNames.Add(nextCandidate)) + { + return nextCandidate; + } + + suffix++; + } + } + + private static string SanitizeFunctionName(string name) + { + // Remove invalid characters and ensure the function name is valid for kernel + var sanitized = new string(name.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray()); + + // Ensure it starts with a letter or underscore + if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]) && sanitized[0] != '_') + { + sanitized = "_" + sanitized; + } + + // Ensure it's not empty + return string.IsNullOrEmpty(sanitized) ? "_CustomAction" : sanitized; + } + + private Task ExecuteCustomActionAsync(Kernel kernel, string fixedPrompt) => + ExecuteTransformAsync( + kernel, + new ActionChainItem(PasteFormats.CustomTextTransformation, Arguments: new() { { PromptParameterName, fixedPrompt } }), + async dataPackageView => + { + var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); + var result = await _customActionTransformService.TransformTextAsync(fixedPrompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); + return DataPackageHelpers.CreateFromText(result?.Content ?? string.Empty); + }); private Task ExecutePromptTransformAsync(Kernel kernel, PasteFormats format, string prompt) => ExecuteTransformAsync( @@ -212,7 +313,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi new ActionChainItem(format, Arguments: new() { { PromptParameterName, prompt } }), async dataPackageView => { - var input = await dataPackageView.GetTextAsync(); + var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); string output = await GetPromptBasedOutput(format, prompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); return DataPackageHelpers.CreateFromText(output); }); @@ -220,7 +321,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task GetPromptBasedOutput(PasteFormats format, string prompt, string input, CancellationToken cancellationToken, IProgress progress) => format switch { - PasteFormats.CustomTextTransformation => await _customTextTransformService.TransformTextAsync(prompt, input, cancellationToken, progress), + PasteFormats.CustomTextTransformation => (await _customActionTransformService.TransformTextAsync(prompt, input, cancellationToken, progress))?.Content ?? string.Empty, _ => throw new ArgumentException($"Unsupported format {format} for prompt transform", nameof(format)), }; @@ -281,4 +382,9 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi var usageString = usage.HasUsage ? $" [{usage}]" : string.Empty; return $"-> {role}: {redactedContent}{usageString}"; } + + protected virtual bool ShouldModerateAdvancedAI() + { + return false; + } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs deleted file mode 100644 index b6aa156b9d..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -using AdvancedPaste.Helpers; -using AdvancedPaste.Models; -using AdvancedPaste.Telemetry; -using Azure; -using Azure.AI.OpenAI; -using ManagedCommon; -using Microsoft.PowerToys.Telemetry; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class CustomTextTransformService(IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService) : ICustomTextTransformService -{ - private const string ModelName = "gpt-3.5-turbo-instruct"; - - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - private readonly IPromptModerationService _promptModerationService = promptModerationService; - - private async Task GetAICompletionAsync(string systemInstructions, string userMessage, CancellationToken cancellationToken) - { - var fullPrompt = systemInstructions + "\n\n" + userMessage; - - await _promptModerationService.ValidateAsync(fullPrompt, cancellationToken); - - OpenAIClient azureAIClient = new(_aiCredentialsProvider.Key); - - var response = await azureAIClient.GetCompletionsAsync( - new() - { - DeploymentName = ModelName, - Prompts = - { - fullPrompt, - }, - Temperature = 0.01F, - MaxTokens = 2000, - }, - cancellationToken); - - if (response.Value.Choices[0].FinishReason == "length") - { - Logger.LogDebug("Cut off due to length constraints"); - } - - return response; - } - - public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) - { - if (string.IsNullOrWhiteSpace(prompt)) - { - return string.Empty; - } - - if (string.IsNullOrWhiteSpace(inputText)) - { - Logger.LogWarning("Clipboard has no usable text data"); - return string.Empty; - } - - string systemInstructions = -$@"You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. -Do not output anything else besides the reformatted clipboard content."; - - string userMessage = -$@"User instructions: -{prompt} - -Clipboard Content: -{inputText} - -Output: -"; - - try - { - var response = await GetAICompletionAsync(systemInstructions, userMessage, cancellationToken); - - var usage = response.Usage; - AdvancedPasteGenerateCustomFormatEvent telemetryEvent = new(usage.PromptTokens, usage.CompletionTokens, ModelName); - PowerToysTelemetry.Log.WriteEvent(telemetryEvent); - var logEvent = new AIServiceFormatEvent(telemetryEvent); - - Logger.LogDebug($"{nameof(TransformTextAsync)} complete; {logEvent.ToJsonString()}"); - - return response.Choices[0].Text; - } - catch (Exception ex) - { - Logger.LogError($"{nameof(TransformTextAsync)} failed", ex); - - AdvancedPasteGenerateCustomErrorEvent errorEvent = new(ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); - PowerToysTelemetry.Log.WriteEvent(errorEvent); - - if (ex is PasteActionException or OperationCanceledException) - { - throw; - } - else - { - throw new PasteActionException(ErrorHelpers.TranslateErrorText((ex as RequestFailedException)?.Status ?? -1), ex); - } - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs deleted file mode 100644 index b19a6d51cb..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Generic; - -using AdvancedPaste.Models; -using Azure.AI.OpenAI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.OpenAI; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class KernelService(IKernelQueryCacheService queryCacheService, IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : - KernelServiceBase(queryCacheService, promptModerationService, customTextTransformService) -{ - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - - protected override string ModelName => "gpt-4o"; - - protected override PromptExecutionSettings PromptExecutionSettings => - new OpenAIPromptExecutionSettings() - { - ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions, - Temperature = 0.01, - }; - - protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) => kernelBuilder.AddOpenAIChatCompletion(ModelName, _aiCredentialsProvider.Key); - - protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) => - chatMessage.Metadata?.GetValueOrDefault("Usage") is CompletionsUsage completionsUsage - ? new(PromptTokens: completionsUsage.PromptTokens, CompletionTokens: completionsUsage.CompletionTokens) - : AIServiceUsage.None; -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs index 0ca15e4161..2668300526 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; using ManagedCommon; using OpenAI.Moderations; @@ -23,7 +24,16 @@ public sealed class PromptModerationService(IAICredentialsProvider aiCredentials { try { - ModerationClient moderationClient = new(ModelName, _aiCredentialsProvider.Key); + _aiCredentialsProvider.Refresh(); + var apiKey = _aiCredentialsProvider.GetKey()?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(apiKey)) + { + Logger.LogWarning("Skipping OpenAI moderation because no credential is configured."); + return; + } + + ModerationClient moderationClient = new(ModelName, apiKey); var moderationClientResult = await moderationClient.ClassifyTextAsync(fullPrompt, cancellationToken); var moderationResult = moderationClientResult.Value; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs deleted file mode 100644 index 169c1c2422..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; - -using Windows.Security.Credentials; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class VaultCredentialsProvider : IAICredentialsProvider -{ - public VaultCredentialsProvider() => Refresh(); - - public string Key { get; private set; } - - public bool IsConfigured => !string.IsNullOrEmpty(Key); - - public bool Refresh() - { - var oldKey = Key; - Key = LoadKey(); - return oldKey != Key; - } - - private static string LoadKey() - { - try - { - return new PasswordVault().Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey")?.Password ?? string.Empty; - } - catch (Exception) - { - return string.Empty; - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs index 5d6740977b..aef9e39bb9 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -8,15 +8,16 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; using Microsoft.PowerToys.Telemetry; using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTextTransformService customTextTransformService) : IPasteFormatExecutor +public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor { private readonly IKernelService _kernelService = kernelService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress progress) { @@ -36,7 +37,7 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTex pasteFormat.Format switch { PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress), - PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText(await _customTextTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetTextAsync(), cancellationToken, progress)), + PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetClipboardTextOrThrowAsync(cancellationToken), cancellationToken, progress))?.Content ?? string.Empty), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw index 604cbf403b..f365778321 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw +++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw @@ -144,16 +144,67 @@ The paste operation was moderated due to sensitive content. Please try another query. - + Clipboard history Clipboard history + + AI provider selector + + + Select an AI provider + + + Active provider: {0} + + + Configured models + + + No models configured + + + Configure models in Settings + Image data Label used to represent an image in the clipboard history + + Text + + + Image + + + Audio + + + Video + + + File + + + Clipboard + + + Copied just now + + + Copied {0} sec ago + + + Copied {0} min ago + + + Copied {0} hr ago + + + Copied {0} day ago + More options @@ -196,7 +247,7 @@ Transcode to .mp3 Option to transcode audio files to MP3 format - + Transcode to .mp4 (H.264/AAC) Option to transcode video files to MP4 format with H.264 video codec and AAC audio codec @@ -272,11 +323,11 @@ Next result - - OpenAI Privacy + + Privacy Policy - - OpenAI Terms + + Terms To custom with AI is disabled by your organization @@ -287,4 +338,38 @@ PowerToys_Paste_ + + Just now + + + 1 minute ago + + + {0} minutes ago + + + Today, {0} + + + Yesterday, {0} + + + {0}, {1} + (e.g., “Wednesday, 17:05”) + + + {0}, {1} + (e.g., "10/20/2025, 17:05" in the user's locale) + + + You are using a custom endpoint. Verify all answers. + + + Local + Badge label displayed next to local AI model providers (e.g., Ollama, Foundry Local) to indicate the model runs locally + + + Unable to load Foundry Local model: {0} + {0} is the model identifier. Do not translate {0}. + \ No newline at end of file diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs new file mode 100644 index 0000000000..06f45a98ae --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace AdvancedPaste.Telemetry; + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public sealed class AdvancedPasteCustomActionErrorEvent : EventBase, IEvent +{ + public AdvancedPasteCustomActionErrorEvent(AIServiceType providerType, string modelName, int statusCode, string error) + { + ProviderType = providerType.ToString(); + ModelName = modelName; + StatusCode = statusCode; + Error = error; + } + + public string ProviderType { get; set; } + + public string ModelName { get; set; } + + public int StatusCode { get; set; } + + public string Error { get; set; } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs new file mode 100644 index 0000000000..671f6a7b9c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace AdvancedPaste.Telemetry; + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class AdvancedPasteEndpointUsageEvent : EventBase, IEvent +{ + /// + /// Gets or sets the AI provider type (e.g., OpenAI, AzureOpenAI, Google). + /// + public string ProviderType { get; set; } + + /// + /// Gets or sets the configured model name. + /// + public string ModelName { get; set; } + + /// + /// Gets or sets a value indicating whether the advanced AI pipeline was used. + /// + public bool IsAdvanced { get; set; } + + /// + /// Gets or sets the total duration in milliseconds, or -1 if unavailable. + /// + public int DurationMs { get; set; } + + public AdvancedPasteEndpointUsageEvent(AIServiceType providerType, string modelName, bool isAdvanced, int durationMs = -1) + { + ProviderType = providerType.ToString(); + ModelName = modelName; + IsAdvanced = isAdvanced; + DurationMs = durationMs; + } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs index 70542da6c8..53b4008782 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs @@ -14,7 +14,7 @@ namespace AdvancedPaste.Telemetry; [EventData] [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] -public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSavedQuery, int promptTokens, int completionTokens, string modelName, string actionChain) : EventBase, IEvent +public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSavedQuery, int promptTokens, int completionTokens, string modelName, string providerType, string actionChain) : EventBase, IEvent { public static string FormatActionChain(IEnumerable actionChain) => FormatActionChain(actionChain.Select(item => item.Format)); @@ -30,6 +30,8 @@ public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSaved public string ModelName { get; set; } = modelName; + public string ProviderType { get; set; } = providerType; + ///
/// Gets or sets a comma-separated list of paste formats used - in the same order they were executed. /// Conceptually an array but formatted this way to work around https://github.com/dotnet/runtime/issues/10428 diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 688c3047e2..8edd9b76ad 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Globalization; using System.IO.Abstractions; using System.Linq; using System.Runtime.InteropServices; @@ -22,6 +23,8 @@ using CommunityToolkit.Mvvm.Input; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.System; @@ -37,12 +40,20 @@ namespace AdvancedPaste.ViewModels private readonly DispatcherTimer _clipboardTimer; private readonly IUserSettings _userSettings; private readonly IPasteFormatExecutor _pasteFormatExecutor; - private readonly IAICredentialsProvider _aiCredentialsProvider; + private readonly IAICredentialsProvider _credentialsProvider; private CancellationTokenSource _pasteActionCancellationTokenSource; + private string _currentClipboardHistoryId; + private DateTimeOffset? _currentClipboardTimestamp; + private ClipboardFormat _lastClipboardFormats = ClipboardFormat.None; + private bool _clipboardHistoryUnavailableLogged; + public DataPackageView ClipboardData { get; set; } + [ObservableProperty] + private ClipboardItem _currentClipboardItem; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] [NotifyPropertyChangedFor(nameof(ClipboardHasData))] @@ -52,12 +63,21 @@ namespace AdvancedPaste.ViewModels private ClipboardFormat _availableClipboardFormats; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowClipboardHistoryButton))] private bool _clipboardHistoryEnabled; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CustomAIUnavailableErrorText))] [NotifyPropertyChangedFor(nameof(IsCustomAIServiceEnabled))] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] + [NotifyPropertyChangedFor(nameof(AllowedAIProviders))] + [NotifyPropertyChangedFor(nameof(ActiveAIProvider))] + [NotifyPropertyChangedFor(nameof(ActiveAIProviderTooltip))] + [NotifyPropertyChangedFor(nameof(TermsLinkUri))] + [NotifyPropertyChangedFor(nameof(PrivacyLinkUri))] + [NotifyPropertyChangedFor(nameof(HasTermsLink))] + [NotifyPropertyChangedFor(nameof(HasPrivacyLink))] + [NotifyPropertyChangedFor(nameof(HasLegalLinks))] private bool _isAllowedByGPO; [ObservableProperty] @@ -79,19 +99,146 @@ namespace AdvancedPaste.ViewModels public ObservableCollection CustomActionPasteFormats { get; } = []; - public bool IsCustomAIServiceEnabled => IsAllowedByGPO && _aiCredentialsProvider.IsConfigured; + public bool IsCustomAIServiceEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + // Check if there are any allowed providers + if (!AllowedAIProviders.Any()) + { + return false; + } + + // We should handle the IsAIEnabled logic in settings, don't check again here. + // If setting says yes, and here should pass check, and if error happens, it happens. + return true; + } + } public bool IsCustomAIAvailable => IsCustomAIServiceEnabled && ClipboardHasDataForCustomAI; - public bool IsAdvancedAIEnabled => IsCustomAIServiceEnabled && _userSettings.IsAdvancedAIEnabled; + public bool IsAdvancedAIEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + if (!TryResolveAdvancedAIProvider(out _)) + { + return false; + } + + return _credentialsProvider.IsConfigured(); + } + } + + public ObservableCollection AIProviders => _userSettings?.PasteAIConfiguration?.Providers ?? new ObservableCollection(); + + public IEnumerable AllowedAIProviders + { + get + { + var providers = AIProviders; + if (providers is null || providers.Count == 0) + { + return Enumerable.Empty(); + } + + return providers.Where(IsProviderAllowedByGPO); + } + } + + public PasteAIProviderDefinition ActiveAIProvider + { + get + { + var provider = _userSettings?.PasteAIConfiguration?.ActiveProvider; + if (provider is null || !IsProviderAllowedByGPO(provider)) + { + return null; + } + + return provider; + } + } + + public string ActiveAIProviderTooltip + { + get + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var provider = ActiveAIProvider; + + if (provider is null) + { + return resourceLoader.GetString("AIProviderButtonTooltipEmpty"); + } + + var format = resourceLoader.GetString("AIProviderButtonTooltipFormat"); + var displayName = provider.DisplayName; + + if (!string.IsNullOrEmpty(format)) + { + return string.Format(CultureInfo.CurrentCulture, format, displayName); + } + + return displayName; + } + } + + private AIServiceTypeMetadata GetActiveProviderMetadata() + { + var provider = ActiveAIProvider ?? AllowedAIProviders.FirstOrDefault(); + var serviceType = provider?.ServiceTypeKind ?? AIServiceType.OpenAI; + return AIServiceTypeRegistry.GetMetadata(serviceType); + } + + public Uri TermsLinkUri + { + get + { + var metadata = GetActiveProviderMetadata(); + return metadata.HasTermsLink ? metadata.TermsUri : null; + } + } + + public Uri PrivacyLinkUri + { + get + { + var metadata = GetActiveProviderMetadata(); + return metadata.HasPrivacyLink ? metadata.PrivacyUri : null; + } + } + + public bool HasTermsLink => GetActiveProviderMetadata().HasTermsLink; + + public bool HasPrivacyLink => GetActiveProviderMetadata().HasPrivacyLink; + + public bool HasLegalLinks => HasTermsLink || HasPrivacyLink; public bool ClipboardHasData => AvailableClipboardFormats != ClipboardFormat.None; public bool ClipboardHasDataForCustomAI => PasteFormat.SupportsClipboardFormats(CustomAIFormat, AvailableClipboardFormats); + public bool ShowClipboardPreview => _userSettings.EnableClipboardPreview; + + public bool ShowClipboardHistoryButton => ClipboardHistoryEnabled; + public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress); - private PasteFormats CustomAIFormat => _userSettings.IsAdvancedAIEnabled ? PasteFormats.KernelQuery : PasteFormats.CustomTextTransformation; + private PasteFormats CustomAIFormat => + _userSettings.IsAIEnabled && TryResolveAdvancedAIProvider(out _) + ? PasteFormats.KernelQuery + : PasteFormats.CustomTextTransformation; private bool Visible { @@ -110,9 +257,9 @@ namespace AdvancedPaste.ViewModels public event EventHandler PreviewRequested; - public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider aiCredentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) + public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) { - _aiCredentialsProvider = aiCredentialsProvider; + _credentialsProvider = credentialsProvider; _userSettings = userSettings; _pasteFormatExecutor = pasteFormatExecutor; @@ -130,6 +277,7 @@ namespace AdvancedPaste.ViewModels _clipboardTimer.Start(); RefreshPasteFormats(); + UpdateAIProviderActiveFlags(); _userSettings.Changed += UserSettings_Changed; PropertyChanged += (_, e) => { @@ -158,15 +306,21 @@ namespace AdvancedPaste.ViewModels if (Visible) { await ReadClipboardAsync(); - UpdateAllowedByGPO(); } } private void UserSettings_Changed(object sender, EventArgs e) { + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); OnPropertyChanged(nameof(ClipboardHasDataForCustomAI)); OnPropertyChanged(nameof(IsCustomAIAvailable)); OnPropertyChanged(nameof(IsAdvancedAIEnabled)); + OnPropertyChanged(nameof(AIProviders)); + OnPropertyChanged(nameof(AllowedAIProviders)); + OnPropertyChanged(nameof(ShowClipboardPreview)); + + NotifyActiveProviderChanged(); EnqueueRefreshPasteFormats(); } @@ -192,6 +346,33 @@ namespace AdvancedPaste.ViewModels private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) => PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled); + private void UpdateAIProviderActiveFlags() + { + var providers = _userSettings?.PasteAIConfiguration?.Providers; + if (providers is not null) + { + var activeId = ActiveAIProvider?.Id; + + foreach (var provider in providers) + { + provider.IsActive = !string.IsNullOrEmpty(activeId) && string.Equals(provider.Id, activeId, StringComparison.OrdinalIgnoreCase); + } + } + + NotifyActiveProviderChanged(); + } + + private void NotifyActiveProviderChanged() + { + OnPropertyChanged(nameof(ActiveAIProvider)); + OnPropertyChanged(nameof(ActiveAIProviderTooltip)); + OnPropertyChanged(nameof(TermsLinkUri)); + OnPropertyChanged(nameof(PrivacyLinkUri)); + OnPropertyChanged(nameof(HasTermsLink)); + OnPropertyChanged(nameof(HasPrivacyLink)); + OnPropertyChanged(nameof(HasLegalLinks)); + } + private void RefreshPasteFormats() { var ctrlString = ResourceLoaderInstance.ResourceLoader.GetString("CtrlKey"); @@ -253,8 +434,96 @@ namespace AdvancedPaste.ViewModels return; } - ClipboardData = Clipboard.GetContent(); - AvailableClipboardFormats = await ClipboardData.GetAvailableFormatsAsync(); + try + { + ClipboardData = Clipboard.GetContent(); + AvailableClipboardFormats = ClipboardData != null ? await ClipboardData.GetAvailableFormatsAsync() : ClipboardFormat.None; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + // Logger.LogDebug("Failed to read clipboard content", ex); + ClipboardData = null; + AvailableClipboardFormats = ClipboardFormat.None; + } + + await UpdateClipboardPreviewAsync(); + } + + private async Task UpdateClipboardPreviewAsync() + { + if (ClipboardData is null || !ClipboardHasData) + { + ResetClipboardPreview(); + _currentClipboardHistoryId = null; + _currentClipboardTimestamp = null; + _lastClipboardFormats = ClipboardFormat.None; + return; + } + + var formatsChanged = AvailableClipboardFormats != _lastClipboardFormats; + _lastClipboardFormats = AvailableClipboardFormats; + + var clipboardChanged = await UpdateClipboardTimestampAsync(formatsChanged); + + // Create ClipboardItem directly from current clipboard data using helper + CurrentClipboardItem = await ClipboardItemHelper.CreateFromCurrentClipboardAsync( + ClipboardData, + AvailableClipboardFormats, + _currentClipboardTimestamp, + clipboardChanged ? null : CurrentClipboardItem?.Image); + } + + private async Task UpdateClipboardTimestampAsync(bool formatsChanged) + { + bool clipboardChanged = formatsChanged; + + if (Clipboard.IsHistoryEnabled()) + { + try + { + var historyItems = await Clipboard.GetHistoryItemsAsync(); + if (historyItems.Status == ClipboardHistoryItemsResultStatus.Success && historyItems.Items.Count > 0) + { + var latest = historyItems.Items[0]; + if (_currentClipboardHistoryId != latest.Id) + { + clipboardChanged = true; + _currentClipboardHistoryId = latest.Id; + } + + _currentClipboardTimestamp = latest.Timestamp; + _clipboardHistoryUnavailableLogged = false; + return clipboardChanged; + } + } + catch (Exception ex) + { + if (!_clipboardHistoryUnavailableLogged) + { + Logger.LogDebug("Failed to access clipboard history timestamp", ex.Message); + _clipboardHistoryUnavailableLogged = true; + } + } + } + + if (!_currentClipboardTimestamp.HasValue || clipboardChanged) + { + _currentClipboardTimestamp = DateTimeOffset.Now; + clipboardChanged = true; + } + + return clipboardChanged; + } + + private void ResetClipboardPreview() + { + // Clear to avoid leaks due to Garbage Collection not clearing the bitmap from memory + if (CurrentClipboardItem?.Image is not null) + { + CurrentClipboardItem.Image.ClearValue(BitmapImage.UriSourceProperty); + } + + CurrentClipboardItem = null; } public async Task OnShowAsync() @@ -270,7 +539,7 @@ namespace AdvancedPaste.ViewModels _dispatcherQueue.TryEnqueue(() => { - GetMainWindow()?.FinishLoading(_aiCredentialsProvider.IsConfigured); + GetMainWindow()?.FinishLoading(IsCustomAIServiceEnabled); OnPropertyChanged(nameof(InputTxtBoxPlaceholderText)); OnPropertyChanged(nameof(CustomAIUnavailableErrorText)); OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); @@ -319,7 +588,7 @@ namespace AdvancedPaste.ViewModels return ResourceLoaderInstance.ResourceLoader.GetString("OpenAIGpoDisabled"); } - if (!_aiCredentialsProvider.IsConfigured) + if (!IsCustomAIServiceEnabled) { return ResourceLoaderInstance.ResourceLoader.GetString("OpenAINotConfigured"); } @@ -515,11 +784,113 @@ namespace AdvancedPaste.ViewModels IsAllowedByGPO = PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOnlineAIModelsValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled; } + private bool IsProviderAllowedByGPO(PasteAIProviderDefinition provider) + { + if (provider is null) + { + return false; + } + + var serviceType = provider.ServiceType.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + + // Check global online AI GPO for online services + if (metadata.IsOnlineService && !IsAllowedByGPO) + { + return false; + } + + // Check individual endpoint GPO + return serviceType switch + { + AIServiceType.OpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureOpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureAIInference => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureAIInferenceValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Mistral => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteMistralValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Google => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteGoogleValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Ollama => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOllamaValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.FoundryLocal => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteFoundryLocalValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + _ => true, // Allow unknown types by default + }; + } + + private bool TryResolveAdvancedAIProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = _userSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedAIProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedAIProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedAIProvider(PasteAIProviderDefinition provider) + { + return provider is not null && provider.EnableAdvancedAI && SupportsAdvancedAI(provider.ServiceTypeKind); + } + + private static bool SupportsAdvancedAI(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI + or AIServiceType.AzureOpenAI; + } + private bool UpdateOpenAIKey() { UpdateAllowedByGPO(); - return IsAllowedByGPO && _aiCredentialsProvider.Refresh(); + return _credentialsProvider.Refresh(); + } + + [RelayCommand] + private async Task SetActiveProviderAsync(PasteAIProviderDefinition provider) + { + if (provider is null || string.IsNullOrEmpty(provider.Id)) + { + return; + } + + if (string.Equals(ActiveAIProvider?.Id, provider.Id, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + await _userSettings.SetActiveAIProviderAsync(provider.Id); + } + catch (Exception ex) + { + Logger.LogError("Failed to activate AI provider", ex); + return; + } + + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(AIProviders)); + NotifyActiveProviderChanged(); + EnqueueRefreshPasteFormats(); } public async Task CancelPasteActionAsync() diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj index c5f02534cd..c023c3c3c1 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj @@ -2,7 +2,7 @@ - + 15.0 diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 6af0d636ac..c7d22d474f 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -16,7 +16,8 @@ #include #include -#include +#include +#include #include BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) @@ -54,12 +55,14 @@ namespace const wchar_t JSON_KEY_ADVANCED_PASTE_UI_HOTKEY[] = L"advanced-paste-ui-hotkey"; const wchar_t JSON_KEY_PASTE_AS_MARKDOWN_HOTKEY[] = L"paste-as-markdown-hotkey"; const wchar_t JSON_KEY_PASTE_AS_JSON_HOTKEY[] = L"paste-as-json-hotkey"; - const wchar_t JSON_KEY_IS_ADVANCED_AI_ENABLED[] = L"IsAdvancedAIEnabled"; + const wchar_t JSON_KEY_IS_AI_ENABLED[] = L"IsAIEnabled"; + const wchar_t JSON_KEY_IS_OPEN_AI_ENABLED[] = L"IsOpenAIEnabled"; const wchar_t JSON_KEY_SHOW_CUSTOM_PREVIEW[] = L"ShowCustomPreview"; + const wchar_t JSON_KEY_PASTE_AI_CONFIGURATION[] = L"paste-ai-configuration"; + const wchar_t JSON_KEY_PROVIDERS[] = L"providers"; + const wchar_t JSON_KEY_SERVICE_TYPE[] = L"service-type"; + const wchar_t JSON_KEY_ENABLE_ADVANCED_AI[] = L"enable-advanced-ai"; const wchar_t JSON_KEY_VALUE[] = L"value"; - - const wchar_t OPENAI_VAULT_RESOURCE[] = L"https://platform.openai.com/api-keys"; - const wchar_t OPENAI_VAULT_USERNAME[] = L"PowerToys_AdvancedPaste_OpenAIKey"; } class AdvancedPaste : public PowertoyModuleIface @@ -94,6 +97,7 @@ private: using CustomAction = ActionData; std::vector m_custom_actions; + bool m_is_ai_enabled = false; bool m_is_advanced_ai_enabled = false; bool m_preview_custom_format_output = true; @@ -145,32 +149,11 @@ private: return jsonObject; } - static bool open_ai_key_exists() - { - try - { - winrt::Windows::Security::Credentials::PasswordVault().Retrieve(OPENAI_VAULT_RESOURCE, OPENAI_VAULT_USERNAME); - return true; - } - catch (const winrt::hresult_error& ex) - { - // Looks like the only way to access the PasswordVault is through an API that throws an exception in case the resource doesn't exist. - // If the debugger breaks here, just continue. - // If you want to disable breaking here in a more permanent way, just add a condition in Visual Studio's Exception Settings to not break on win::hresult_error, but that might make you not hit other exceptions you might want to catch. - if (ex.code() == HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) - { - return false; // Credential doesn't exist. - } - Logger::error("Unexpected error while retrieving OpenAI key from vault: {}", winrt::to_string(ex.message())); - return false; - } - } - - bool is_open_ai_enabled() + bool is_ai_enabled() { return gpo_policy_enabled_configuration() != powertoys_gpo::gpo_rule_configured_disabled && powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue() != powertoys_gpo::gpo_rule_configured_disabled && - open_ai_key_exists(); + m_is_ai_enabled; } static std::wstring kebab_to_pascal_case(const std::wstring& kebab_str) @@ -201,6 +184,13 @@ private: return result; } + static std::wstring to_lower_case(const std::wstring& value) + { + std::wstring result = value; + std::transform(result.begin(), result.end(), result.begin(), [](wchar_t ch) { return std::towlower(ch); }); + return result; + } + bool migrate_data_and_remove_data_file(Hotkey& old_paste_as_plain_hotkey) { const wchar_t OLD_JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut"; @@ -267,6 +257,61 @@ private: } } + bool has_advanced_ai_provider(const winrt::Windows::Data::Json::JsonObject& propertiesObject) + { + if (!propertiesObject.HasKey(JSON_KEY_PASTE_AI_CONFIGURATION)) + { + return false; + } + + const auto configValue = propertiesObject.GetNamedValue(JSON_KEY_PASTE_AI_CONFIGURATION); + if (configValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + return false; + } + + const auto configObject = configValue.GetObjectW(); + if (!configObject.HasKey(JSON_KEY_PROVIDERS)) + { + return false; + } + + const auto providersValue = configObject.GetNamedValue(JSON_KEY_PROVIDERS); + if (providersValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Array) + { + return false; + } + + const auto providers = providersValue.GetArray(); + for (const auto providerValue : providers) + { + if (providerValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + continue; + } + + const auto providerObject = providerValue.GetObjectW(); + if (!providerObject.GetNamedBoolean(JSON_KEY_ENABLE_ADVANCED_AI, false)) + { + continue; + } + + if (!providerObject.HasKey(JSON_KEY_SERVICE_TYPE)) + { + continue; + } + + const std::wstring serviceType = providerObject.GetNamedString(JSON_KEY_SERVICE_TYPE, L"").c_str(); + const auto normalizedServiceType = to_lower_case(serviceType); + if (normalizedServiceType == L"openai" || normalizedServiceType == L"azureopenai") + { + return true; + } + } + + return false; + } + void read_settings(PowerToysSettings::PowerToyValues& settings) { const auto settingsObject = settings.get_raw_json(); @@ -341,7 +386,7 @@ private: if (propertiesObject.HasKey(JSON_KEY_CUSTOM_ACTIONS)) { const auto customActions = propertiesObject.GetNamedObject(JSON_KEY_CUSTOM_ACTIONS).GetNamedArray(JSON_KEY_VALUE); - if (customActions.Size() > 0 && is_open_ai_enabled()) + if (customActions.Size() > 0 && is_ai_enabled()) { for (const auto& customAction : customActions) { @@ -365,9 +410,19 @@ private: { const auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); - if (propertiesObject.HasKey(JSON_KEY_IS_ADVANCED_AI_ENABLED)) + m_is_advanced_ai_enabled = has_advanced_ai_provider(propertiesObject); + + if (propertiesObject.HasKey(JSON_KEY_IS_AI_ENABLED)) { - m_is_advanced_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_ADVANCED_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE); + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else if (propertiesObject.HasKey(JSON_KEY_IS_OPEN_AI_ENABLED)) + { + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_OPEN_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else + { + m_is_ai_enabled = false; } if (propertiesObject.HasKey(JSON_KEY_SHOW_CUSTOM_PREVIEW)) diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json index 31ad05c701..bc0803796e 100644 --- a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json @@ -1 +1 @@ -{"properties":{"IsAdvancedAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file +{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file diff --git a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj index 32541aab12..30e8230284 100644 --- a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj +++ b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj @@ -82,6 +82,8 @@ Disabled _DEBUG;%(PreprocessorDefinitions) + MultiThreadedDebug + MultiThreadedDebug false @@ -93,6 +95,8 @@ true true NDEBUG;%(PreprocessorDefinitions) + MultiThreaded + MultiThreaded true diff --git a/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj b/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj index 184eec3342..c4489cdad8 100644 --- a/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj @@ -39,6 +39,7 @@ _DEBUG;%(PreprocessorDefinitions) + MultiThreadedDebugDLL true true @@ -48,6 +49,7 @@ NDEBUG;%(PreprocessorDefinitions) true true + MultiThreadedDLL false true false diff --git a/src/modules/Hosts/Hosts.FuzzTests/FuzzTests.cs b/src/modules/Hosts/Hosts.FuzzTests/FuzzTests.cs index 36c7705549..c6fdb14e97 100644 --- a/src/modules/Hosts/Hosts.FuzzTests/FuzzTests.cs +++ b/src/modules/Hosts/Hosts.FuzzTests/FuzzTests.cs @@ -1,11 +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; -using System.IO; -using System.IO.Abstractions.TestingHelpers; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Hosts.Tests.Mocks; using HostsUILib.Helpers; @@ -19,6 +15,7 @@ namespace Hosts.FuzzTests { private static Mock _userSettings; private static Mock _elevationHelper; + private static Mock _backupManager; // Case1: Fuzzing method for ValidIPv4 public static void FuzzValidIPv4(ReadOnlySpan input) @@ -73,9 +70,10 @@ namespace Hosts.FuzzTests _userSettings = new Mock(); _elevationHelper = new Mock(); _elevationHelper.Setup(m => m.IsElevated).Returns(true); + _backupManager = new Mock(); var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); string input = System.Text.Encoding.UTF8.GetString(data); diff --git a/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj b/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj index 667cfcc0ad..51dee7a40b 100644 --- a/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj +++ b/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj @@ -30,8 +30,11 @@ + + + diff --git a/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs b/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs new file mode 100644 index 0000000000..6aeb834029 --- /dev/null +++ b/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO.Abstractions.TestingHelpers; +using HostsUILib.Helpers; +using HostsUILib.Settings; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Hosts.Tests +{ + [TestClass] + public class BackupManagerTest + { + private const string HostsPath = @"C:\Windows\System32\Drivers\etc\hosts"; + private const string BackupPath = @"C:\Backup\hosts"; + private const string BackupSearchPattern = $"*_PowerToysBackup_*"; + + [TestMethod] + public void Hosts_Backup_Not_Executed() + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, true); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupHosts).Returns(false); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Create(HostsPath); + + Assert.AreEqual(0, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + } + + [TestMethod] + public void Hosts_Backup_Executed_Once() + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, true); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupHosts).Returns(true); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Create(HostsPath); + backupManager.Create(HostsPath); + + Assert.AreEqual(1, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + var hostsContent = fileSystem.File.ReadAllText(HostsPath); + var backupContent = fileSystem.File.ReadAllText(fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern)[0]); + Assert.AreEqual(hostsContent, backupContent); + } + + [DataTestMethod] + [DataRow(-10, -10)] + [DataRow(-10, 0)] + [DataRow(-10, 10)] + [DataRow(0, -10)] + [DataRow(0, 0)] + [DataRow(0, 10)] + [DataRow(10, -10)] + [DataRow(10, 0)] + [DataRow(10, 10)] + public void Hosts_Backups_Delete_Never(int count, int days) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Never); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(30, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(31, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [DataTestMethod] + [DataRow(-10, 30)] + [DataRow(0, 30)] + [DataRow(10, 10)] + public void Hosts_Backups_Delete_ByCount(int count, int expectedBackups) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Count); + userSettings.Setup(m => m.DeleteBackupsCount).Returns(count); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(expectedBackups, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(expectedBackups + 1, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [DataTestMethod] + [DataRow(-10, -10, 30)] + [DataRow(-10, 0, 30)] + [DataRow(-10, 10, 5)] + [DataRow(0, -10, 30)] + [DataRow(0, 0, 30)] + [DataRow(0, 10, 5)] + [DataRow(10, -10, 30)] + [DataRow(10, 0, 30)] + [DataRow(5, 1, 5)] + [DataRow(1, 15, 10)] + [DataRow(2, 2, 2)] + public void Hosts_Backups_Delete_ByAge(int count, int days, int expectedBackups) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Age); + userSettings.Setup(m => m.DeleteBackupsCount).Returns(count); + userSettings.Setup(m => m.DeleteBackupsDays).Returns(days); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(expectedBackups, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(expectedBackups + 1, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + private void SetupFiles(MockFileSystem fileSystem, bool hostsOnly) + { + fileSystem.AddDirectory(BackupPath); + fileSystem.AddFile(HostsPath, new MockFileData("HOSTS FILE CONTENT")); + + if (hostsOnly) + { + return; + } + + var today = new DateTimeOffset(DateTime.Today); + + var notBackupData = new MockFileData("NOT A BACKUP") + { + CreationTime = today.AddDays(-100), + }; + + fileSystem.AddFile(fileSystem.Path.Combine(BackupPath, "hosts_not_a_backup"), notBackupData); + + // The first backup is from 5 days ago. There are 30 backups, one for each day. + var offset = 5; + for (var i = 0; i < 30; i++) + { + var backupData = new MockFileData("THIS IS A BACKUP") + { + CreationTime = today.AddDays(-i - offset), + }; + + fileSystem.AddFile(fileSystem.Path.Combine(BackupPath, $"hosts_PowerToysBackup_{i}"), backupData); + } + } + } +} diff --git a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs index 81052fd101..4c6ee77f8c 100644 --- a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs +++ b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs @@ -20,8 +20,10 @@ namespace Hosts.Tests [TestClass] public class HostsServiceTest { + private const string BackupPath = @"C:\Backup\hosts"; private static Mock _userSettings; private static Mock _elevationHelper; + private static Mock _backupManager; [ClassInitialize] public static void ClassInitialize(TestContext context) @@ -29,27 +31,7 @@ namespace Hosts.Tests _userSettings = new Mock(); _elevationHelper = new Mock(); _elevationHelper.Setup(m => m.IsElevated).Returns(true); - } - - [TestMethod] - public void Hosts_Exists() - { - var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); - fileSystem.AddFile(service.HostsFilePath, new MockFileData(string.Empty)); - var result = service.Exists(); - - Assert.IsTrue(result); - } - - [TestMethod] - public void Hosts_Not_Exists() - { - var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); - var result = service.Exists(); - - Assert.IsFalse(result); + _backupManager = new Mock(); } [TestMethod] @@ -67,7 +49,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -92,7 +74,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -118,7 +100,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -137,7 +119,7 @@ namespace Hosts.Tests public async Task Empty_Hosts() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(string.Empty)); await service.WriteAsync(string.Empty, Enumerable.Empty()); @@ -168,7 +150,7 @@ namespace Hosts.Tests var fileSystem = new CustomMockFileSystem(); var userSettings = new Mock(); userSettings.Setup(m => m.AdditionalLinesPosition).Returns(HostsAdditionalLinesPosition.Top); - var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -200,7 +182,7 @@ namespace Hosts.Tests var fileSystem = new CustomMockFileSystem(); var userSettings = new Mock(); userSettings.Setup(m => m.AdditionalLinesPosition).Returns(HostsAdditionalLinesPosition.Bottom); - var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -224,7 +206,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -241,7 +223,7 @@ namespace Hosts.Tests var elevationHelper = new Mock(); elevationHelper.Setup(m => m.IsElevated).Returns(false); - var service = new HostsService(fileSystem, _userSettings.Object, elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, elevationHelper.Object, _backupManager.Object); await Assert.ThrowsExceptionAsync(async () => await service.WriteAsync("# Empty hosts file", Enumerable.Empty())); } @@ -249,7 +231,7 @@ namespace Hosts.Tests public async Task Save_ReadOnlyHostsException() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -265,7 +247,7 @@ namespace Hosts.Tests public void Remove_ReadOnly_Attribute() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -284,7 +266,7 @@ namespace Hosts.Tests public async Task Save_Hidden_Hosts() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -316,7 +298,7 @@ namespace Hosts.Tests var fs = new CustomMockFileSystem(); var settings = new Mock(); settings.Setup(s => s.NoLeadingSpaces).Returns(true); - var svc = new HostsService(fs, settings.Object, _elevationHelper.Object); + var svc = new HostsService(fs, settings.Object, _elevationHelper.Object, _backupManager.Object); fs.AddFile(svc.HostsFilePath, new MockFileData(content)); var data = await svc.ReadAsync(); @@ -327,5 +309,57 @@ namespace Hosts.Tests var result = fs.GetFile(svc.HostsFilePath); Assert.AreEqual(expected, result.TextContents); } + + [TestMethod] + public async Task Hosts_Backup_Not_Executed() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new CustomMockFileSystem(); + fileSystem.AddDirectory(BackupPath); + _userSettings.Setup(m => m.BackupHosts).Returns(false); + _userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, _userSettings.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, backupManager); + + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var data = await service.ReadAsync(); + var entries = data.Entries.ToList(); + entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false)); + await service.WriteAsync(data.AdditionalLines, data.Entries); + + Assert.AreEqual(0, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [TestMethod] + public async Task Hosts_Backup_Executed_Once() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new CustomMockFileSystem(); + _userSettings.Setup(m => m.BackupHosts).Returns(true); + _userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, _userSettings.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, backupManager); + + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var data = await service.ReadAsync(); + var entries = data.Entries.ToList(); + entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false)); + await service.WriteAsync(data.AdditionalLines, data.Entries); + await service.WriteAsync(data.AdditionalLines, data.Entries); + + Assert.AreEqual(1, fileSystem.Directory.GetFiles(BackupPath).Length); + var backupContent = fileSystem.File.ReadAllText(fileSystem.Directory.GetFiles(BackupPath)[0]); + Assert.AreEqual(content, backupContent); + } } } diff --git a/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs b/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs index fbe5d3662d..8dbec70de8 100644 --- a/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs +++ b/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs @@ -56,6 +56,7 @@ namespace Hosts { // Core Services services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -74,7 +75,7 @@ namespace Hosts }). Build(); - var cleanupBackupThread = new Thread(() => + var deleteBackupThread = new Thread(() => { // Delete old backups only if running elevated if (!Host.GetService().IsElevated) @@ -84,7 +85,7 @@ namespace Hosts try { - Host.GetService().CleanupBackup(); + Host.GetService().Delete(); } catch (Exception ex) { @@ -92,8 +93,8 @@ namespace Hosts } }); - cleanupBackupThread.IsBackground = true; - cleanupBackupThread.Start(); + deleteBackupThread.IsBackground = true; + deleteBackupThread.Start(); UnhandledException += App_UnhandledException; diff --git a/src/modules/Hosts/Hosts/Settings/UserSettings.cs b/src/modules/Hosts/Hosts/Settings/UserSettings.cs index 75da5d214d..038823f0e2 100644 --- a/src/modules/Hosts/Hosts/Settings/UserSettings.cs +++ b/src/modules/Hosts/Hosts/Settings/UserSettings.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -45,17 +45,34 @@ namespace Hosts.Settings public HostsAdditionalLinesPosition AdditionalLinesPosition { get; private set; } // Moved from Settings.UI.Library - public HostsEncoding Encoding { get; set; } + public HostsEncoding Encoding { get; private set; } + + public bool BackupHosts { get; private set; } + + public string BackupPath { get; private set; } + + // Moved from Settings.UI.Library + public HostsDeleteBackupMode DeleteBackupsMode { get; private set; } + + public int DeleteBackupsDays { get; private set; } + + public int DeleteBackupsCount { get; private set; } public event EventHandler LoopbackDuplicatesChanged; public UserSettings() { _settingsUtils = new SettingsUtils(); - ShowStartupWarning = true; - LoopbackDuplicates = false; - AdditionalLinesPosition = HostsAdditionalLinesPosition.Top; - Encoding = HostsEncoding.Utf8; + var defaultSettings = new HostsProperties(); + ShowStartupWarning = defaultSettings.ShowStartupWarning; + LoopbackDuplicates = defaultSettings.LoopbackDuplicates; + AdditionalLinesPosition = (HostsAdditionalLinesPosition)defaultSettings.AdditionalLinesPosition; + Encoding = (HostsEncoding)defaultSettings.Encoding; + BackupHosts = defaultSettings.BackupHosts; + BackupPath = defaultSettings.BackupPath; + DeleteBackupsMode = (HostsDeleteBackupMode)defaultSettings.DeleteBackupsMode; + DeleteBackupsDays = defaultSettings.DeleteBackupsDays; + DeleteBackupsCount = defaultSettings.DeleteBackupsCount; LoadSettingsFromJson(); @@ -91,6 +108,11 @@ namespace Hosts.Settings Encoding = (HostsEncoding)settings.Properties.Encoding; LoopbackDuplicates = settings.Properties.LoopbackDuplicates; NoLeadingSpaces = settings.Properties.NoLeadingSpaces; + BackupHosts = settings.Properties.BackupHosts; + BackupPath = settings.Properties.BackupPath; + DeleteBackupsMode = (HostsDeleteBackupMode)settings.Properties.DeleteBackupsMode; + DeleteBackupsDays = settings.Properties.DeleteBackupsDays; + DeleteBackupsCount = settings.Properties.DeleteBackupsCount; } retry = false; diff --git a/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj b/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj index 10605f18ad..8c1ac3b636 100644 --- a/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj +++ b/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj @@ -2,7 +2,7 @@ - + 16.0 @@ -46,7 +46,7 @@ - $(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories) + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) diff --git a/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs b/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs new file mode 100644 index 0000000000..5417408409 --- /dev/null +++ b/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO.Abstractions; +using System.Linq; +using HostsUILib.Settings; + +namespace HostsUILib.Helpers +{ + public class BackupManager : IBackupManager + { + private const string BackupSuffix = "_PowerToysBackup_"; + private readonly IFileSystem _fileSystem; + private readonly IUserSettings _userSettings; + private bool _backupDone; + + public BackupManager(IFileSystem fileSystem, IUserSettings userSettings) + { + _fileSystem = fileSystem; + _userSettings = userSettings; + } + + public void Create(string hostsFilePath) + { + if (_backupDone || !_userSettings.BackupHosts || !_fileSystem.File.Exists(hostsFilePath)) + { + return; + } + + try + { + if (!_fileSystem.Directory.Exists(_userSettings.BackupPath)) + { + _fileSystem.Directory.CreateDirectory(_userSettings.BackupPath); + } + + var backupPath = _fileSystem.Path.Combine(_userSettings.BackupPath, $"hosts{BackupSuffix}{DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}"); + + _fileSystem.File.Copy(hostsFilePath, backupPath); + _backupDone = true; + } + catch (Exception ex) + { + LoggerInstance.Logger.LogError("Backup failed", ex); + } + } + + public void Delete() + { + switch (_userSettings.DeleteBackupsMode) + { + case HostsDeleteBackupMode.Count: + DeleteByCount(_userSettings.DeleteBackupsCount); + break; + case HostsDeleteBackupMode.Age: + DeleteByAge(_userSettings.DeleteBackupsDays, _userSettings.DeleteBackupsCount); + break; + } + } + + public void DeleteByCount(int count) + { + if (count < 1) + { + return; + } + + var backups = GetAll().OrderByDescending(f => f.CreationTime).Skip(count).ToArray(); + DeleteAll(backups); + } + + public void DeleteByAge(int days, int count) + { + if (days < 1) + { + return; + } + + var backupsEnumerable = GetAll(); + + if (count > 0) + { + backupsEnumerable = backupsEnumerable.OrderByDescending(f => f.CreationTime).Skip(count); + } + + var backups = backupsEnumerable.Where(f => f.CreationTime < DateTime.Now.AddDays(-days)).ToArray(); + DeleteAll(backups); + } + + private IEnumerable GetAll() + { + if (!_fileSystem.Directory.Exists(_userSettings.BackupPath)) + { + return []; + } + + return _fileSystem.Directory.GetFiles(_userSettings.BackupPath, $"*{BackupSuffix}*").Select(_fileSystem.FileInfo.New); + } + + private void DeleteAll(IFileInfo[] files) + { + foreach (var f in files) + { + _fileSystem.File.Delete(f.FullName); + } + } + } +} diff --git a/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs b/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs index 83aa3544b1..9b16e04f20 100644 --- a/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs +++ b/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -23,16 +22,15 @@ namespace HostsUILib.Helpers { public partial class HostsService : IHostsService, IDisposable { - private const string _backupSuffix = $"_PowerToysBackup_"; - private const int _defaultBufferSize = 4096; // From System.IO.File source code + private const int DefaultBufferSize = 4096; // From System.IO.File source code private readonly SemaphoreSlim _asyncLock = new SemaphoreSlim(1, 1); private readonly IFileSystem _fileSystem; private readonly IUserSettings _userSettings; private readonly IElevationHelper _elevationHelper; private readonly IFileSystemWatcher _fileSystemWatcher; + private readonly IBackupManager _backupManager; private readonly string _hostsFilePath; - private bool _backupDone; private bool _disposed; public string HostsFilePath => _hostsFilePath; @@ -44,11 +42,13 @@ namespace HostsUILib.Helpers public HostsService( IFileSystem fileSystem, IUserSettings userSettings, - IElevationHelper elevationHelper) + IElevationHelper elevationHelper, + IBackupManager backupManager) { _fileSystem = fileSystem; _userSettings = userSettings; _elevationHelper = elevationHelper; + _backupManager = backupManager; _hostsFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"System32\drivers\etc\hosts"); @@ -60,18 +60,13 @@ namespace HostsUILib.Helpers _fileSystemWatcher.EnableRaisingEvents = true; } - public bool Exists() - { - return _fileSystem.File.Exists(HostsFilePath); - } - public async Task ReadAsync() { var entries = new List(); var unparsedBuilder = new StringBuilder(); var splittedEntries = false; - if (!Exists()) + if (!_fileSystem.File.Exists(HostsFilePath)) { return new HostsData(entries, unparsedBuilder.ToString(), false); } @@ -192,15 +187,10 @@ namespace HostsUILib.Helpers { await _asyncLock.WaitAsync(); _fileSystemWatcher.EnableRaisingEvents = false; - - if (!_backupDone && Exists()) - { - _fileSystem.File.Copy(HostsFilePath, HostsFilePath + _backupSuffix + DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)); - _backupDone = true; - } + _backupManager.Create(HostsFilePath); // FileMode.OpenOrCreate is necessary to prevent UnauthorizedAccessException when the hosts file is hidden - using var stream = _fileSystem.FileStream.New(HostsFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read, _defaultBufferSize, FileOptions.Asynchronous); + using var stream = _fileSystem.FileStream.New(HostsFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read, DefaultBufferSize, FileOptions.Asynchronous); using var writer = new StreamWriter(stream, Encoding); foreach (var line in lines) { @@ -231,15 +221,6 @@ namespace HostsUILib.Helpers } } - public void CleanupBackup() - { - Directory.GetFiles(Path.GetDirectoryName(HostsFilePath), $"*{_backupSuffix}*") - .Select(f => new FileInfo(f)) - .Where(f => f.CreationTime < DateTime.Now.AddDays(-15)) - .ToList() - .ForEach(f => f.Delete()); - } - public void OpenHostsFile() { var notepadFallback = false; diff --git a/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.cs b/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.cs new file mode 100644 index 0000000000..9da9802a26 --- /dev/null +++ b/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace HostsUILib.Helpers +{ + public interface IBackupManager + { + void Create(string hostsFilePath); + + void Delete(); + } +} diff --git a/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs b/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs index fe75946a12..c6f2678156 100644 --- a/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs +++ b/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs @@ -22,8 +22,6 @@ namespace HostsUILib.Helpers Task PingAsync(string address); - void CleanupBackup(); - void OpenHostsFile(); void RemoveReadOnlyAttribute(); diff --git a/src/modules/Hosts/HostsUILib/Settings/HostsDeleteBackupMode.cs b/src/modules/Hosts/HostsUILib/Settings/HostsDeleteBackupMode.cs new file mode 100644 index 0000000000..d1e1d79ded --- /dev/null +++ b/src/modules/Hosts/HostsUILib/Settings/HostsDeleteBackupMode.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace HostsUILib.Settings +{ + public enum HostsDeleteBackupMode + { + Never = 0, + Count = 1, + Age = 2, + } +} diff --git a/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs b/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs index 46c7a7dab5..4f175398ad 100644 --- a/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs +++ b/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs @@ -16,6 +16,16 @@ namespace HostsUILib.Settings public HostsEncoding Encoding { get; } + public bool BackupHosts { get; } + + public string BackupPath { get; } + + public HostsDeleteBackupMode DeleteBackupsMode { get; } + + public int DeleteBackupsDays { get; } + + public int DeleteBackupsCount { get; } + event EventHandler LoopbackDuplicatesChanged; public delegate void OpenSettingsFunction(); diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp index fc93c4d387..845e24fa93 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -11,19 +11,17 @@ #include #include #include -#include +#include "LightSwitchStateManager.h" +#include SERVICE_STATUS g_ServiceStatus = {}; SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; HANDLE g_ServiceStopEvent = nullptr; -extern int g_lastUpdatedDay = -1; -static ScheduleMode prevMode = ScheduleMode::Off; -static std::wstring prevLat, prevLon; -static int prevMinutes = -1; VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); DWORD WINAPI ServiceWorkerThread(LPVOID lpParam); +void ApplyTheme(bool shouldBeLight); // Entry point for the executable int _tmain(int argc, TCHAR* argv[]) @@ -124,31 +122,66 @@ VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl) } } -static void update_sun_times(auto& settings) +void ApplyTheme(bool shouldBeLight) { - double latitude = std::stod(settings.latitude); - double longitude = std::stod(settings.longitude); + const auto& s = LightSwitchSettings::settings(); + + if (s.changeSystem) + { + bool isSystemCurrentlyLight = GetCurrentSystemTheme(); + if (shouldBeLight != isSystemCurrentlyLight) + { + SetSystemTheme(shouldBeLight); + Logger::info(L"[LightSwitchService] Changed system theme to {}.", shouldBeLight ? L"light" : L"dark"); + } + } + + if (s.changeApps) + { + bool isAppsCurrentlyLight = GetCurrentAppsTheme(); + if (shouldBeLight != isAppsCurrentlyLight) + { + SetAppsTheme(shouldBeLight); + Logger::info(L"[LightSwitchService] Changed apps theme to {}.", shouldBeLight ? L"light" : L"dark"); + } + } +} + +static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateManager) +{ + const auto& s = LightSwitchSettings::settings(); + if (s.scheduleMode == ScheduleMode::Off) + return; SYSTEMTIME st; GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; - SunTimes newTimes = CalculateSunriseSunset(latitude, longitude, st.wYear, st.wMonth, st.wDay); + // Compute effective boundaries (with offsets if needed) + int effectiveLight = s.lightTime; + int effectiveDark = s.darkTime; - int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute; - int newDarkTime = newTimes.sunsetHour * 60 + newTimes.sunsetMinute; - try + if (s.scheduleMode == ScheduleMode::SunsetToSunrise) { - auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); - values.add_property(L"lightTime", newLightTime); - values.add_property(L"darkTime", newDarkTime); - values.save_to_settings_file(); - - Logger::info(L"[LightSwitchService] Updated sun times and saved to config."); + effectiveLight = (s.lightTime + s.sunrise_offset) % 1440; + effectiveDark = (s.darkTime + s.sunset_offset) % 1440; } - catch (const std::exception& e) + + // Use shared helper (handles wraparound logic) + bool shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + + // Compare current system/apps theme + bool currentSystemLight = GetCurrentSystemTheme(); + bool currentAppsLight = GetCurrentAppsTheme(); + + bool systemMismatch = s.changeSystem && (currentSystemLight != shouldBeLight); + bool appsMismatch = s.changeApps && (currentAppsLight != shouldBeLight); + + // Trigger manual override only if mismatch and not already active + if ((systemMismatch || appsMismatch) && !stateManager.GetState().isManualOverride) { - std::wstring wmsg(e.what(), e.what() + strlen(e.what())); - Logger::error(L"[LightSwitchService] Exception during sun time update: {}", wmsg); + Logger::info(L"[LightSwitchService] External theme change detected (Windows Settings). Entering manual override mode."); + stateManager.OnManualOverride(); } } @@ -162,326 +195,16 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) Logger::info(L"[LightSwitchService] Worker thread starting..."); Logger::info(L"[LightSwitchService] Parent PID: {}", parentPid); + // ──────────────────────────────────────────────────────────────── + // Initialization + // ──────────────────────────────────────────────────────────────── + static LightSwitchStateManager stateManager; + LightSwitchSettings::instance().InitFileWatcher(); - LightSwitchServiceObserver observer({ SettingId::LightTime, - SettingId::DarkTime, - SettingId::ScheduleMode, - SettingId::Sunrise_Offset, - SettingId::Sunset_Offset }); - HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent(); - auto applyTheme = [](int nowMinutes, int lightMinutes, int darkMinutes, const auto& settings) { - bool isLightActive = (lightMinutes < darkMinutes) ? (nowMinutes >= lightMinutes && nowMinutes < darkMinutes) : (nowMinutes >= lightMinutes || nowMinutes < darkMinutes); - - bool isSystemCurrentlyLight = GetCurrentSystemTheme(); - bool isAppsCurrentlyLight = GetCurrentAppsTheme(); - - if (isLightActive) - { - if (settings.changeSystem && !isSystemCurrentlyLight) - { - SetSystemTheme(true); - Logger::info(L"[LightSwitchService] Changing system theme to light mode."); - } - if (settings.changeApps && !isAppsCurrentlyLight) - { - SetAppsTheme(true); - Logger::info(L"[LightSwitchService] Changing apps theme to light mode."); - } - } - else - { - if (settings.changeSystem && isSystemCurrentlyLight) - { - SetSystemTheme(false); - Logger::info(L"[LightSwitchService] Changing system theme to dark mode."); - } - if (settings.changeApps && isAppsCurrentlyLight) - { - SetAppsTheme(false); - Logger::info(L"[LightSwitchService] Changing apps theme to dark mode."); - } - } - }; - - LightSwitchSettings::instance().LoadSettings(); - auto& settings = LightSwitchSettings::instance().settings(); - - SYSTEMTIME st; - GetLocalTime(&st); - int nowMinutes = st.wHour * 60 + st.wMinute; - - if (settings.scheduleMode != ScheduleMode::Off) - { - applyTheme(nowMinutes, - settings.lightTime + settings.sunrise_offset, - settings.darkTime + settings.sunset_offset, - settings); - Logger::trace(L"[LightSwitchService] Initialized g_lastUpdatedDay = {}", g_lastUpdatedDay); - } - else - { - Logger::info(L"[LightSwitchService] Schedule mode is OFF - ticker suspended, waiting for manual action or mode change."); - } - - g_lastUpdatedDay = st.wDay; - ULONGLONG lastSettingsReload = 0; - - for (;;) - { - HANDLE waits[2] = { g_ServiceStopEvent, hParent }; - DWORD count = hParent ? 2 : 1; - bool skipRest = false; - - const auto& settings = LightSwitchSettings::instance().settings(); - - bool scheduleJustEnabled = (prevMode == ScheduleMode::Off && settings.scheduleMode != ScheduleMode::Off); - prevMode = settings.scheduleMode; - - // ─── Handle "Schedule Off" Mode ───────────────────────────────────────────── - if (settings.scheduleMode == ScheduleMode::Off) - { - Logger::info(L"[LightSwitchService] Schedule mode OFF - suspending scheduler but keeping service alive."); - - if (!hManualOverride) - hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); - - HANDLE waitsOff[4]; - DWORD countOff = 0; - waitsOff[countOff++] = g_ServiceStopEvent; - if (hParent) - waitsOff[countOff++] = hParent; - if (hManualOverride) - waitsOff[countOff++] = hManualOverride; - waitsOff[countOff++] = LightSwitchSettings::instance().GetSettingsChangedEvent(); - - for (;;) - { - DWORD wait = WaitForMultipleObjects(countOff, waitsOff, FALSE, INFINITE); - - if (wait == WAIT_OBJECT_0) - { - Logger::info(L"[LightSwitchService] Stop event triggered - exiting worker loop."); - goto cleanup; - } - if (hParent && wait == WAIT_OBJECT_0 + 1) - { - Logger::info(L"[LightSwitchService] Parent exited - stopping service."); - goto cleanup; - } - - if (wait == WAIT_OBJECT_0 + (hParent ? 2 : 1)) - { - Logger::info(L"[LightSwitchService] Manual override received while schedule OFF."); - ResetEvent(hManualOverride); - continue; - } - - if (wait == WAIT_OBJECT_0 + (hParent ? 3 : 2)) - { - Logger::trace(L"[LightSwitchService] Settings change event triggered, reloading settings..."); - ResetEvent(LightSwitchSettings::instance().GetSettingsChangedEvent()); - LightSwitchSettings::instance().LoadSettings(); - const auto& newSettings = LightSwitchSettings::instance().settings(); - lastSettingsReload = GetTickCount64(); - - if (newSettings.scheduleMode != ScheduleMode::Off) - { - Logger::info(L"[LightSwitchService] Schedule re-enabled, resuming normal loop."); - break; - } - } - } - - continue; - } - - // ─── Normal Schedule Loop ─────────────────────────────────────────────────── - ULONGLONG nowTick = GetTickCount64(); - bool recentSettingsReload = (nowTick - lastSettingsReload < 5000); - - if (g_lastUpdatedDay != -1) - { - bool manualOverrideActive = (hManualOverride && WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); - - if (settings.scheduleMode != ScheduleMode::Off && !recentSettingsReload && !scheduleJustEnabled) - { - Logger::debug(L"[LightSwitchService] Checking if manual override is active..."); - bool manualOverrideActive = (hManualOverride && WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); - Logger::debug(L"[LightSwitchService] Manual override active = {}", manualOverrideActive); - - if (!manualOverrideActive) - { - bool currentSystemTheme = GetCurrentSystemTheme(); - bool currentAppsTheme = GetCurrentAppsTheme(); - - SYSTEMTIME st; - GetLocalTime(&st); - int nowMinutes = st.wHour * 60 + st.wMinute; - - bool shouldBeLight = (settings.lightTime < settings.darkTime) ? (nowMinutes >= settings.lightTime && nowMinutes < settings.darkTime) : (nowMinutes >= settings.lightTime || nowMinutes < settings.darkTime); - - Logger::debug(L"[LightSwitchService] shouldBeLight = {}", shouldBeLight); - - if ((settings.changeSystem && (currentSystemTheme != shouldBeLight)) || - (settings.changeApps && (currentAppsTheme != shouldBeLight))) - { - Logger::debug(L"[LightSwitchService] External theme change detected - enabling manual override"); - - if (!hManualOverride) - { - hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); - if (!hManualOverride) - hManualOverride = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); - } - - if (hManualOverride) - { - SetEvent(hManualOverride); - Logger::info(L"[LightSwitchService] Detected manual theme change outside of LightSwitch. Triggering manual override."); - skipRest = true; - } - } - } - } - else - { - Logger::debug(L"[LightSwitchService] Skipping external-change detection (schedule off, recent reload, or just enabled)."); - } - } - - // ─── Apply Schedule Logic ─────────────────────────────────────────────────── - if (!skipRest) - { - bool modeChangedToSunset = (prevMode != settings.scheduleMode && - settings.scheduleMode == ScheduleMode::SunsetToSunrise); - bool coordsChanged = (prevLat != settings.latitude || prevLon != settings.longitude); - - if ((modeChangedToSunset || coordsChanged) && settings.scheduleMode == ScheduleMode::SunsetToSunrise) - { - Logger::info(L"[LightSwitchService] Mode or coordinates changed, recalculating sun times."); - update_sun_times(settings); - SYSTEMTIME st; - GetLocalTime(&st); - g_lastUpdatedDay = st.wDay; - prevMode = settings.scheduleMode; - prevLat = settings.latitude; - prevLon = settings.longitude; - } - - SYSTEMTIME st; - GetLocalTime(&st); - int nowMinutes = st.wHour * 60 + st.wMinute; - - if ((g_lastUpdatedDay != st.wDay) && (settings.scheduleMode == ScheduleMode::SunsetToSunrise)) - { - update_sun_times(settings); - g_lastUpdatedDay = st.wDay; - prevMinutes = -1; - Logger::info(L"[LightSwitchService] Recalculated sun times at new day boundary."); - } - - LightSwitchSettings::instance().LoadSettings(); - const auto& currentSettings = LightSwitchSettings::instance().settings(); - - wchar_t msg[160]; - swprintf_s(msg, - L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d | mode=%d", - st.wHour, - st.wMinute, - currentSettings.lightTime / 60, - currentSettings.lightTime % 60, - currentSettings.darkTime / 60, - currentSettings.darkTime % 60, - static_cast(currentSettings.scheduleMode)); - Logger::info(msg); - - bool manualOverrideActive = false; - if (hManualOverride) - manualOverrideActive = (WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); - - if (manualOverrideActive) - { - int lightBoundary = (currentSettings.lightTime + currentSettings.sunrise_offset) % 1440; - int darkBoundary = (currentSettings.darkTime + currentSettings.sunset_offset) % 1440; - - bool crossedLight = false; - bool crossedDark = false; - - if (prevMinutes != -1) - { - if (nowMinutes < prevMinutes) - { - crossedLight = (prevMinutes <= lightBoundary || nowMinutes >= lightBoundary); - crossedDark = (prevMinutes <= darkBoundary || nowMinutes >= darkBoundary); - } - else - { - crossedLight = (prevMinutes < lightBoundary && nowMinutes >= lightBoundary); - crossedDark = (prevMinutes < darkBoundary && nowMinutes >= darkBoundary); - } - } - - Logger::debug(L"[LightSwitchService] prevMinutes={} nowMinutes={} light={} dark={}", - prevMinutes, - nowMinutes, - lightBoundary, - darkBoundary); - - if (crossedLight || crossedDark) - { - ResetEvent(hManualOverride); - Logger::info(L"[LightSwitchService] Manual override cleared after crossing schedule boundary."); - } - else - { - Logger::info(L"[LightSwitchService] Skipping schedule due to manual override"); - skipRest = true; - } - } - - if (!skipRest) - applyTheme(nowMinutes, - currentSettings.lightTime + currentSettings.sunrise_offset, - currentSettings.darkTime + currentSettings.sunset_offset, - currentSettings); - } - - // ─── Wait For Next Minute Tick Or Stop Event ──────────────────────────────── - SYSTEMTIME st; - GetLocalTime(&st); - int msToNextMinute = (60 - st.wSecond) * 1000 - st.wMilliseconds; - if (msToNextMinute < 50) - msToNextMinute = 50; - - prevMinutes = nowMinutes; - - DWORD wait = WaitForMultipleObjects(count, waits, FALSE, msToNextMinute); - if (wait == WAIT_OBJECT_0) - { - Logger::info(L"[LightSwitchService] Stop event triggered - exiting worker loop."); - break; - } - if (hParent && wait == WAIT_OBJECT_0 + 1) - { - Logger::info(L"[LightSwitchService] Parent process exited - stopping service."); - break; - } - } - -cleanup: - if (hManualOverride) - CloseHandle(hManualOverride); - if (hParent) - CloseHandle(hParent); - - return 0; -} - -void ApplyThemeNow() -{ LightSwitchSettings::instance().LoadSettings(); const auto& settings = LightSwitchSettings::instance().settings(); @@ -489,43 +212,82 @@ void ApplyThemeNow() GetLocalTime(&st); int nowMinutes = st.wHour * 60 + st.wMinute; - bool shouldBeLight = false; - if (settings.lightTime < settings.darkTime) - shouldBeLight = (nowMinutes >= settings.lightTime && nowMinutes < settings.darkTime); - else - shouldBeLight = (nowMinutes >= settings.lightTime || nowMinutes < settings.darkTime); + Logger::info(L"[LightSwitchService] Initialized at {:02d}:{:02d}.", st.wHour, st.wMinute); + stateManager.SyncInitialThemeState(); + stateManager.OnTick(nowMinutes); - bool isSystemCurrentlyLight = GetCurrentSystemTheme(); - bool isAppsCurrentlyLight = GetCurrentAppsTheme(); - - Logger::info(L"[LightSwitchService] Applying (if needed) theme immediately due to schedule change."); - - if (shouldBeLight) + // ──────────────────────────────────────────────────────────────── + // Worker Loop + // ──────────────────────────────────────────────────────────────── + for (;;) { - if (settings.changeSystem && !isSystemCurrentlyLight) + HANDLE waits[4]; + DWORD count = 0; + waits[count++] = g_ServiceStopEvent; + if (hParent) + waits[count++] = hParent; + if (hManualOverride) + waits[count++] = hManualOverride; + waits[count++] = hSettingsChanged; + + // Wait for one of these to trigger or for a new minute tick + SYSTEMTIME st; + GetLocalTime(&st); + int msToNextMinute = (60 - st.wSecond) * 1000 - st.wMilliseconds; + if (msToNextMinute < 50) + msToNextMinute = 50; + + DWORD wait = WaitForMultipleObjects(count, waits, FALSE, msToNextMinute); + + if (wait == WAIT_TIMEOUT) { - SetSystemTheme(true); - Logger::info(L"[LightSwitchService] Changing system theme to light mode."); + // regular minute tick + GetLocalTime(&st); + nowMinutes = st.wHour * 60 + st.wMinute; + DetectAndHandleExternalThemeChange(stateManager); + stateManager.OnTick(nowMinutes); + continue; } - if (settings.changeApps && !isAppsCurrentlyLight) + + if (wait == WAIT_OBJECT_0) { - SetAppsTheme(true); - Logger::info(L"[LightSwitchService] Changing apps theme to light mode."); - } - } - else - { - if (settings.changeSystem && isSystemCurrentlyLight) - { - SetSystemTheme(false); - Logger::info(L"[LightSwitchService] Changing system theme to dark mode."); - } - if (settings.changeApps && isAppsCurrentlyLight) - { - SetAppsTheme(false); - Logger::info(L"[LightSwitchService] Changing apps theme to dark mode."); + Logger::info(L"[LightSwitchService] Stop event triggered — exiting."); + break; + } + + if (hParent && wait == WAIT_OBJECT_0 + 1) + { + Logger::info(L"[LightSwitchService] Parent process exited — stopping service."); + break; + } + + if (hManualOverride && wait == WAIT_OBJECT_0 + (hParent ? 2 : 1)) + { + Logger::info(L"[LightSwitchService] Manual override event detected."); + stateManager.OnManualOverride(); + ResetEvent(hManualOverride); + continue; + } + + if (wait == WAIT_OBJECT_0 + (hParent ? (hManualOverride ? 3 : 2) : 2)) + { + ResetEvent(hSettingsChanged); + LightSwitchSettings::instance().LoadSettings(); + stateManager.OnSettingsChanged(); + continue; } } + + // ──────────────────────────────────────────────────────────────── + // Cleanup + // ──────────────────────────────────────────────────────────────── + if (hManualOverride) + CloseHandle(hManualOverride); + if (hParent) + CloseHandle(hParent); + + Logger::info(L"[LightSwitchService] Worker thread exiting cleanly."); + return 0; } int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj index b9406b758e..c026927749 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -74,8 +74,8 @@ - + @@ -85,8 +85,9 @@ - + + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters index 7aa39aa0c2..795df99aba 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -33,7 +33,7 @@ Source Files - + Source Files @@ -56,7 +56,10 @@ Header Files - + + Header Files + + Header Files diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.cpp deleted file mode 100644 index e28a2625fe..0000000000 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.cpp +++ /dev/null @@ -1,29 +0,0 @@ -#include "LightSwitchServiceObserver.h" -#include -#include "LightSwitchSettings.h" - -// These are defined elsewhere in your service module (ServiceWorkerThread.cpp) -extern int g_lastUpdatedDay; -void ApplyThemeNow(); - -void LightSwitchServiceObserver::SettingsUpdate(SettingId id) -{ - Logger::info(L"[LightSwitchService] Setting changed: {}", static_cast(id)); - g_lastUpdatedDay = -1; - ApplyThemeNow(); -} - -bool LightSwitchServiceObserver::WantsToBeNotified(SettingId id) const noexcept -{ - switch (id) - { - case SettingId::LightTime: - case SettingId::DarkTime: - case SettingId::ScheduleMode: - case SettingId::Sunrise_Offset: - case SettingId::Sunset_Offset: - return true; - default: - return false; - } -} diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.h deleted file mode 100644 index 14c88e656d..0000000000 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "SettingsObserver.h" - -// The LightSwitchServiceObserver reacts when LightSwitchSettings changes. -class LightSwitchServiceObserver : public SettingsObserver -{ -public: - explicit LightSwitchServiceObserver(std::unordered_set observedSettings) : - SettingsObserver(std::move(observedSettings)) - { - } - - void SettingsUpdate(SettingId id) override; - bool WantsToBeNotified(SettingId id) const noexcept override; -}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp index 2e00417001..5221a197fe 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp @@ -2,10 +2,8 @@ #include #include #include "SettingsObserver.h" - #include #include -#include #include using namespace std; @@ -38,13 +36,79 @@ void LightSwitchSettings::InitFileWatcher() m_settingsFileWatcher = std::make_unique( GetSettingsFileName(), [this]() { - Logger::info(L"[LightSwitchSettings] Settings file changed, signaling event."); - LoadSettings(); - SetEvent(m_settingsChangedEvent); + using namespace std::chrono; + + { + std::lock_guard lock(m_debounceMutex); + m_lastChangeTime = steady_clock::now(); + if (m_debouncePending) + return; + m_debouncePending = true; + } + + m_debounceThread = std::jthread([this](std::stop_token stop) { + using namespace std::chrono; + while (!stop.stop_requested()) + { + std::this_thread::sleep_for(seconds(3)); + + auto elapsed = steady_clock::now() - m_lastChangeTime; + if (elapsed >= seconds(1)) + break; + } + + { + std::lock_guard lock(m_debounceMutex); + m_debouncePending = false; + } + + Logger::info(L"[LightSwitchSettings] Settings file stabilized, reloading."); + + try + { + LoadSettings(); + SetEvent(m_settingsChangedEvent); + } + catch (const std::exception& e) + { + std::wstring wmsg; + wmsg.assign(e.what(), e.what() + strlen(e.what())); + Logger::error(L"[LightSwitchSettings] Exception during debounced reload: {}", wmsg); + } + }); }); } } +LightSwitchSettings::~LightSwitchSettings() +{ + Logger::info(L"[LightSwitchSettings] Cleaning up settings resources..."); + + // Stop and join the debounce thread (std::jthread auto-joins, but we can signal stop too) + if (m_debounceThread.joinable()) + { + m_debounceThread.request_stop(); + } + + // Release the file watcher so it closes file handles and background threads + if (m_settingsFileWatcher) + { + m_settingsFileWatcher.reset(); + Logger::info(L"[LightSwitchSettings] File watcher stopped."); + } + + // Close the Windows event handle + if (m_settingsChangedEvent) + { + CloseHandle(m_settingsChangedEvent); + m_settingsChangedEvent = nullptr; + Logger::info(L"[LightSwitchSettings] Settings changed event closed."); + } + + Logger::info(L"[LightSwitchSettings] Cleanup complete."); +} + + void LightSwitchSettings::AddObserver(SettingsObserver& observer) { m_observers.insert(&observer); @@ -73,6 +137,7 @@ HANDLE LightSwitchSettings::GetSettingsChangedEvent() const void LightSwitchSettings::LoadSettings() { + std::lock_guard guard(m_settingsMutex); try { PowerToysSettings::PowerToyValues values = @@ -181,4 +246,4 @@ void LightSwitchSettings::LoadSettings() { // 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 index e5e993c696..d4029d072d 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -5,7 +5,10 @@ #include #include #include - +#include +#include +#include +#include #include #include #include @@ -83,7 +86,7 @@ public: private: LightSwitchSettings(); - ~LightSwitchSettings() = default; + ~LightSwitchSettings(); LightSwitchConfig m_settings; std::unique_ptr m_settingsFileWatcher; @@ -92,4 +95,11 @@ private: void NotifyObservers(SettingId id) const; HANDLE m_settingsChangedEvent = nullptr; + mutable std::mutex m_settingsMutex; + + // Debounce state + std::atomic_bool m_debouncePending{ false }; + std::mutex m_debounceMutex; + std::chrono::steady_clock::time_point m_lastChangeTime{}; + std::jthread m_debounceThread; }; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp new file mode 100644 index 0000000000..4fba4ae9a6 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -0,0 +1,232 @@ +#include "pch.h" +#include "LightSwitchStateManager.h" +#include +#include +#include "ThemeScheduler.h" +#include + +void ApplyTheme(bool shouldBeLight); + +// Constructor +LightSwitchStateManager::LightSwitchStateManager() +{ + Logger::info(L"[LightSwitchStateManager] Initialized"); +} + +// Called when settings.json changes +void LightSwitchStateManager::OnSettingsChanged() +{ + std::lock_guard lock(_stateMutex); + + // If manual override was active, clear it so new settings take effect + if (_state.isManualOverride) + { + _state.isManualOverride = false; + } + + EvaluateAndApplyIfNeeded(); +} + +// Called once per minute +void LightSwitchStateManager::OnTick(int currentMinutes) +{ + std::lock_guard lock(_stateMutex); + EvaluateAndApplyIfNeeded(); +} + +// Called when manual override is triggered +void LightSwitchStateManager::OnManualOverride() +{ + std::lock_guard lock(_stateMutex); + Logger::info(L"[LightSwitchStateManager] Manual override triggered"); + _state.isManualOverride = !_state.isManualOverride; + + // When entering manual override, sync internal theme state to match the current system + if (_state.isManualOverride) + { + _state.isSystemLightActive = GetCurrentSystemTheme(); + + _state.isAppsLightActive = GetCurrentAppsTheme(); + + Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).", + (_state.isSystemLightActive ? L"light" : L"dark"), + (_state.isAppsLightActive ? L"light" : L"dark")); + } + + EvaluateAndApplyIfNeeded(); +} + +// Helpers +bool LightSwitchStateManager::CoordinatesAreValid(const std::wstring& lat, const std::wstring& lon) +{ + try + { + double latVal = std::stod(lat); + double lonVal = std::stod(lon); + return !(latVal == 0 && lonVal == 0) && (latVal >= -90.0 && latVal <= 90.0) && (lonVal >= -180.0 && lonVal <= 180.0); + } + catch (...) + { + return false; + } +} + +void LightSwitchStateManager::SyncInitialThemeState() +{ + std::lock_guard lock(_stateMutex); + _state.isSystemLightActive = GetCurrentSystemTheme(); + _state.isAppsLightActive = GetCurrentAppsTheme(); + Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})", + _state.isSystemLightActive ? L"light" : L"dark"); + Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})", + _state.isAppsLightActive ? L"light" : L"dark"); +} + +static std::pair update_sun_times(auto& settings) +{ + double latitude = std::stod(settings.latitude); + double longitude = std::stod(settings.longitude); + + SYSTEMTIME st; + GetLocalTime(&st); + + SunTimes newTimes = CalculateSunriseSunset(latitude, longitude, st.wYear, st.wMonth, st.wDay); + + int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute; + int newDarkTime = newTimes.sunsetHour * 60 + newTimes.sunsetMinute; + + try + { + auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); + values.add_property(L"lightTime", newLightTime); + values.add_property(L"darkTime", newDarkTime); + values.save_to_settings_file(); + + Logger::info(L"[LightSwitchService] Updated sun times and saved to config."); + } + catch (const std::exception& e) + { + std::string msg = e.what(); + std::wstring wmsg(msg.begin(), msg.end()); + Logger::error(L"[LightSwitchService] Exception during sun time update: {}", wmsg); + } + + return { newLightTime, newDarkTime }; +} + +// Internal: decide what should happen now +void LightSwitchStateManager::EvaluateAndApplyIfNeeded() +{ + LightSwitchSettings::instance().LoadSettings(); + const auto& _currentSettings = LightSwitchSettings::settings(); + auto now = GetNowMinutes(); + + // Early exit: OFF mode just pauses activity + if (_currentSettings.scheduleMode == ScheduleMode::Off) + { + _state.lastTickMinutes = now; + return; + } + + bool coordsValid = CoordinatesAreValid(_currentSettings.latitude, _currentSettings.longitude); + + // Handle Sun Mode recalculation + if (_currentSettings.scheduleMode == ScheduleMode::SunsetToSunrise && coordsValid) + { + SYSTEMTIME st; + GetLocalTime(&st); + bool newDay = (_state.lastEvaluatedDay != st.wDay); + bool modeChangedToSun = (_state.lastAppliedMode != ScheduleMode::SunsetToSunrise && + _currentSettings.scheduleMode == ScheduleMode::SunsetToSunrise); + + if (newDay || modeChangedToSun) + { + auto [newLightTime, newDarkTime] = update_sun_times(_currentSettings); + _state.lastEvaluatedDay = st.wDay; + _state.effectiveLightMinutes = newLightTime + _currentSettings.sunrise_offset; + _state.effectiveDarkMinutes = newDarkTime + _currentSettings.sunset_offset; + } + else + { + _state.effectiveLightMinutes = _currentSettings.lightTime + _currentSettings.sunrise_offset; + _state.effectiveDarkMinutes = _currentSettings.darkTime + _currentSettings.sunset_offset; + } + } + else if (_currentSettings.scheduleMode == ScheduleMode::FixedHours) + { + _state.effectiveLightMinutes = _currentSettings.lightTime; + _state.effectiveDarkMinutes = _currentSettings.darkTime; + } + + // Handle manual override logic + if (_state.isManualOverride) + { + bool crossedBoundary = false; + if (_state.lastTickMinutes != -1) + { + int prev = _state.lastTickMinutes; + + // Handle midnight wraparound safely + if (now < prev) + { + crossedBoundary = + (prev <= _state.effectiveLightMinutes || now >= _state.effectiveLightMinutes) || + (prev <= _state.effectiveDarkMinutes || now >= _state.effectiveDarkMinutes); + } + else + { + crossedBoundary = + (prev < _state.effectiveLightMinutes && now >= _state.effectiveLightMinutes) || + (prev < _state.effectiveDarkMinutes && now >= _state.effectiveDarkMinutes); + } + } + + if (crossedBoundary) + { + _state.isManualOverride = false; + } + else + { + _state.lastTickMinutes = now; + return; + } + } + + _state.lastAppliedMode = _currentSettings.scheduleMode; + + bool shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + + bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight); + bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight); + + /* Logger::debug( + L"[LightSwitchStateManager] now = {:02d}:{:02d}, light boundary = {:02d}:{:02d} ({}), dark boundary = {:02d}:{:02d} ({})", + now / 60, + now % 60, + _state.effectiveLightMinutes / 60, + _state.effectiveLightMinutes % 60, + _state.effectiveLightMinutes, + _state.effectiveDarkMinutes / 60, + _state.effectiveDarkMinutes % 60, + _state.effectiveDarkMinutes); */ + + /* Logger::debug("should be light = {}, apps needs change = {}, system needs change = {}", + shouldBeLight ? "true" : "false", + appsNeedsToChange ? "true" : "false", + systemNeedsToChange ? "true" : "false"); */ + + // Only apply theme if there's a change or no override active + if (!_state.isManualOverride && (appsNeedsToChange || systemNeedsToChange)) + { + Logger::info(L"[LightSwitchStateManager] Applying {} theme", shouldBeLight ? L"light" : L"dark"); + ApplyTheme(shouldBeLight); + + _state.isSystemLightActive = GetCurrentSystemTheme(); + _state.isAppsLightActive = GetCurrentAppsTheme(); + } + + _state.lastTickMinutes = now; +} + + + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h new file mode 100644 index 0000000000..5c9bcc6e25 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -0,0 +1,47 @@ +#pragma once +#include "LightSwitchSettings.h" +#include + +// Represents runtime-only information (not saved in settings.json) +struct LightSwitchState +{ + ScheduleMode lastAppliedMode = ScheduleMode::Off; + bool isManualOverride = false; + bool isSystemLightActive = false; + bool isAppsLightActive = false; + int lastEvaluatedDay = -1; + int lastTickMinutes = -1; + + // Derived, runtime-resolved times + int effectiveLightMinutes = 0; // the boundary we actually act on + int effectiveDarkMinutes = 0; // includes offsets if needed +}; + +// The controller that reacts to settings changes, time ticks, and manual overrides. +class LightSwitchStateManager +{ +public: + LightSwitchStateManager(); + + // Called when settings.json changes or stabilizes. + void OnSettingsChanged(); + + // Called every minute (from service worker tick). + void OnTick(int currentMinutes); + + // Called when manual override is toggled (via shortcut or system change). + void OnManualOverride(); + + // Initial sync at startup to align internal state with system theme + void SyncInitialThemeState(); + + // Accessor for current state (optional, for debugging or telemetry) + const LightSwitchState& GetState() const { return _state; } + +private: + LightSwitchState _state; + std::mutex _stateMutex; + + void EvaluateAndApplyIfNeeded(); + bool CoordinatesAreValid(const std::wstring& lat, const std::wstring& lon); +}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchUtils.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchUtils.h new file mode 100644 index 0000000000..0f4462bb65 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchUtils.h @@ -0,0 +1,24 @@ +#pragma once +#include + +constexpr bool ShouldBeLight(int nowMinutes, int lightTime, int darkTime) +{ + // Normalize values into [0, 1439] + int normalizedLightTime = (lightTime % 1440 + 1440) % 1440; + int normalizedDarkTime = (darkTime % 1440 + 1440) % 1440; + int normalizedNowMinutes = (nowMinutes % 1440 + 1440) % 1440; + + // Case 1: Normal range, e.g. light mode comes before dark mode in the same day + if (normalizedLightTime < normalizedDarkTime) + return normalizedNowMinutes >= normalizedLightTime && normalizedNowMinutes < normalizedDarkTime; + + // Case 2: Wrap-around range, e.g. light mode starts in the evening and dark mode starts in the morning + return normalizedNowMinutes >= normalizedLightTime || normalizedNowMinutes < normalizedDarkTime; +} + +inline int GetNowMinutes() +{ + SYSTEMTIME st; + GetLocalTime(&st); + return st.wHour * 60 + st.wMinute; +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs index 7b586301f6..37041b4b2d 100644 --- a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs @@ -152,16 +152,16 @@ namespace LightSwitch.UITests var neededTabs = 6; - if (modeCombobox.Text != "Manual") + if (modeCombobox.Text != "Fixed hours") { modeCombobox.Click(); var manualListItem = testBase.Session.Find(By.AccessibilityId("ManualCBItem_LightSwitch"), 5000); - Assert.IsNotNull(manualListItem, "Manual combobox item not found."); + Assert.IsNotNull(manualListItem, "Fixed Hours combobox item not found."); manualListItem.Click(); neededTabs = 1; } - Assert.AreEqual("Manual", modeCombobox.Text, "Mode combobox should be set to Manual."); + Assert.AreEqual("Fixed hours", modeCombobox.Text, "Mode combobox should be set to Fixed hours."); var timeline = testBase.Session.Find(By.AccessibilityId("Timeline_LightSwitch"), 5000); Assert.IsNotNull(timeline, "Timeline not found."); @@ -198,7 +198,7 @@ namespace LightSwitch.UITests } /// - /// Perform a update geolocation test operation + /// Perform a update manual location test operation /// public static void PerformUserSelectedLocationTest(UITestBase testBase) { @@ -216,19 +216,22 @@ namespace LightSwitch.UITests Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + // Click the select location button var setLocationButton = testBase.Session.Find(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); Assert.IsNotNull(setLocationButton, "Set location button not found."); - setLocationButton.Click(); + setLocationButton.Click(msPostAction: 1000); - 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 latitudeBox = testBase.Session.Find(By.AccessibilityId("LatitudeBox_LightSwitch"), 5000); + Assert.IsNotNull(latitudeBox, "Latitude text box not found."); + latitudeBox.Click(); - var latLong = testBase.Session.Find(By.AccessibilityId("LocationResultText_LightSwitch"), 5000); - Assert.IsFalse(string.IsNullOrWhiteSpace(latLong.Text)); + testBase.Session.SendKeys(Key.Up); + + var longitudeBox = testBase.Session.Find(By.AccessibilityId("LongitudeBox_LightSwitch"), 5000); + Assert.IsNotNull(longitudeBox, "Longitude text box not found."); + longitudeBox.Click(); + + testBase.Session.SendKeys(Key.Down); var sunrise = testBase.Session.Find(By.AccessibilityId("SunriseText_LightSwitch"), 5000); Assert.IsFalse(string.IsNullOrWhiteSpace(sunrise.Text)); @@ -256,13 +259,14 @@ namespace LightSwitch.UITests Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); - // Click the select city button + // Click the select location button var setLocationButton = testBase.Session.Find(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); Assert.IsNotNull(setLocationButton, "Set location button not found."); - setLocationButton.Click(msPostAction: 8000); + setLocationButton.Click(msPostAction: 1000); - var latLong = testBase.Session.Find(By.AccessibilityId("LocationResultText_LightSwitch"), 5000); - Assert.IsFalse(string.IsNullOrWhiteSpace(latLong.Text)); + var syncLocationButton = testBase.Session.Find(By.AccessibilityId("SyncLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(syncLocationButton, "Sync location button not found."); + syncLocationButton.Click(msPostAction: 8000); var sunrise = testBase.Session.Find(By.AccessibilityId("SunriseText_LightSwitch"), 5000); Assert.IsFalse(string.IsNullOrWhiteSpace(sunrise.Text)); @@ -363,6 +367,7 @@ namespace LightSwitch.UITests var systemBeforeValue = GetSystemTheme(); var appsBeforeValue = GetAppsTheme(); + Task.Delay(1000).Wait(); testBase.Session.SendKeys(activationKeys); Task.Delay(5000).Wait(); @@ -389,6 +394,7 @@ namespace LightSwitch.UITests var noneSystemBeforeValue = GetSystemTheme(); var noneAppsBeforeValue = GetAppsTheme(); + Task.Delay(1000).Wait(); testBase.Session.SendKeys(activationKeys); Task.Delay(5000).Wait(); diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.rc b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc new file mode 100644 index 0000000000..37752edae0 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc @@ -0,0 +1,46 @@ +#include +#include "resource.h" +#include "../../../../common/version/version.h" + +#define APSTUDIO_READONLY_SYMBOLS +#include "winres.h" +#undef APSTUDIO_READONLY_SYMBOLS + +1 VERSIONINFO + FILEVERSION FILE_VERSION + PRODUCTVERSION PRODUCT_VERSION + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS_NT_WINDOWS32 + FILETYPE VFT_DLL + FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", "PowerToys CursorWrap" + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", "CursorWrap" + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", "PowerToys.CursorWrap.dll" + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +STRINGTABLE +BEGIN + IDS_CURSORWRAP_NAME L"CursorWrap" + IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG L"Disable wrapping during drag" +END \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj new file mode 100644 index 0000000000..59e2095ca7 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj @@ -0,0 +1,130 @@ + + + + + 15.0 + {48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5} + Win32Proj + CursorWrap + CursorWrap + + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\ + PowerToys.CursorWrap + + + true + + + false + + + + Level3 + Disabled + true + _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreadedDebug + stdcpplatest + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Level3 + MaxSpeed + true + true + true + NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreaded + stdcpplatest + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + + + + + + + + + + + Create + + + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h new file mode 100644 index 0000000000..4274ad714f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h @@ -0,0 +1,213 @@ +#pragma once + +#include +#include + +// Test case structure for comprehensive monitor layout testing +struct MonitorTestCase +{ + std::string name; + std::string description; + int grid[3][3]; // 3x3 grid representing monitor layout (0 = no monitor, 1-9 = monitor ID) + + // Test scenarios to validate + struct TestScenario + { + int sourceMonitor; // Which monitor to start cursor on (1-based) + int edgeDirection; // 0=top, 1=right, 2=bottom, 3=left + int expectedTargetMonitor; // Expected destination monitor (1-based, -1 = wrap within same monitor) + std::string description; + }; + + std::vector scenarios; +}; + +// Comprehensive test cases for all possible 3x3 monitor grid configurations +class CursorWrapTestSuite +{ +public: + static std::vector GetAllTestCases() + { + std::vector testCases; + + // Test Case 1: Single monitor (center) + testCases.push_back({ + "Single_Center", + "Single monitor in center position", + { + {0, 0, 0}, + {0, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, -1, "Top edge wraps to bottom of same monitor"}, + {1, 1, -1, "Right edge wraps to left of same monitor"}, + {1, 2, -1, "Bottom edge wraps to top of same monitor"}, + {1, 3, -1, "Left edge wraps to right of same monitor"} + } + }); + + // Test Case 2: Two monitors horizontal (left + right) + testCases.push_back({ + "Dual_Horizontal_Left_Right", + "Two monitors: left + right", + { + {0, 0, 0}, + {1, 0, 2}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom of monitor 1"}, + {1, 1, 2, "Monitor 1 right edge moves to monitor 2 left"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left edge wraps to right of monitor 1"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right edge wraps to left of monitor 2"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, 1, "Monitor 2 left edge moves to monitor 1 right"} + } + }); + + // Test Case 3: Two monitors vertical (Monitor 2 above Monitor 1) - CORRECTED FOR USER'S SETUP + testCases.push_back({ + "Dual_Vertical_2_Above_1", + "Two monitors: Monitor 2 (top) above Monitor 1 (bottom/main)", + { + {0, 2, 0}, // Row 0: Monitor 2 (physically top monitor) + {0, 0, 0}, // Row 1: Empty + {0, 1, 0} // Row 2: Monitor 1 (physically bottom/main monitor) + }, + { + // Monitor 1 (bottom/main monitor) tests + {1, 0, 2, "Monitor 1 (bottom) top edge should move to Monitor 2 (top) bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left wraps to right of monitor 1"}, + + // Monitor 2 (top monitor) tests + {2, 0, -1, "Monitor 2 (top) top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right wraps to left of monitor 2"}, + {2, 2, 1, "Monitor 2 (top) bottom edge should move to Monitor 1 (bottom) top"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"} + } + }); + + // Test Case 4: Three monitors L-shape (center + left + top) + testCases.push_back({ + "Triple_L_Shape", + "Three monitors in L-shape: center + left + top", + { + {0, 3, 0}, + {2, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, 3, "Monitor 1 top moves to monitor 3 bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, 2, "Monitor 1 left moves to monitor 2 right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, 1, "Monitor 2 right moves to monitor 1 left"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"}, + {3, 0, -1, "Monitor 3 top wraps to bottom of monitor 3"}, + {3, 1, -1, "Monitor 3 right wraps to left of monitor 3"}, + {3, 2, 1, "Monitor 3 bottom moves to monitor 1 top"}, + {3, 3, -1, "Monitor 3 left wraps to right of monitor 3"} + } + }); + + // Test Case 5: Three monitors horizontal (left + center + right) + testCases.push_back({ + "Triple_Horizontal", + "Three monitors horizontal: left + center + right", + { + {0, 0, 0}, + {1, 2, 3}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, 2, "Monitor 1 right moves to monitor 2"}, + {1, 2, -1, "Monitor 1 bottom wraps to top"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom"}, + {2, 1, 3, "Monitor 2 right moves to monitor 3"}, + {2, 2, -1, "Monitor 2 bottom wraps to top"}, + {2, 3, 1, "Monitor 2 left moves to monitor 1"}, + {3, 0, -1, "Monitor 3 top wraps to bottom"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, 2, "Monitor 3 left moves to monitor 2"} + } + }); + + // Test Case 6: Three monitors vertical (top + center + bottom) + testCases.push_back({ + "Triple_Vertical", + "Three monitors vertical: top + center + bottom", + { + {0, 1, 0}, + {0, 2, 0}, + {0, 3, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left"}, + {1, 2, 2, "Monitor 1 bottom moves to monitor 2"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, 1, "Monitor 2 top moves to monitor 1"}, + {2, 1, -1, "Monitor 2 right wraps to left"}, + {2, 2, 3, "Monitor 2 bottom moves to monitor 3"}, + {2, 3, -1, "Monitor 2 left wraps to right"}, + {3, 0, 2, "Monitor 3 top moves to monitor 2"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, -1, "Monitor 3 left wraps to right"} + } + }); + + return testCases; + } + + // Helper function to print test case in a readable format + static std::string FormatTestCase(const MonitorTestCase& testCase) + { + std::string result = "Test Case: " + testCase.name + "\n"; + result += "Description: " + testCase.description + "\n"; + result += "Layout:\n"; + + for (int row = 0; row < 3; row++) + { + result += " "; + for (int col = 0; col < 3; col++) + { + if (testCase.grid[row][col] == 0) + { + result += ". "; + } + else + { + result += std::to_string(testCase.grid[row][col]) + " "; + } + } + result += "\n"; + } + + result += "Test Scenarios:\n"; + for (const auto& scenario : testCase.scenarios) + { + result += " - " + scenario.description + "\n"; + } + + return result; + } + + // Helper function to validate a specific test case against actual behavior + static bool ValidateTestCase(const MonitorTestCase& testCase) + { + // This would be called with actual CursorWrap instance to validate behavior + // For now, just return true - this would need actual implementation + return true; + } +}; \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/dllmain.cpp b/src/modules/MouseUtils/CursorWrap/dllmain.cpp new file mode 100644 index 0000000000..74524ed9f9 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -0,0 +1,1046 @@ +#include "pch.h" +#include "../../../interface/powertoy_module_interface.h" +#include "../../../common/SettingsAPI/settings_objects.h" +#include "trace.h" +#include "../../../common/utils/process_path.h" +#include "../../../common/utils/resources.h" +#include "../../../common/logger/logger.h" +#include "../../../common/utils/logger_helper.h" +#include +#include +#include +#include +#include +#include +#include +#include "resource.h" +#include "CursorWrapTests.h" + +// Disable C26451 arithmetic overflow warning for this file since the operations are safe in this context +#pragma warning(disable: 26451) + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +// Non-Localizable strings +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_VALUE[] = L"value"; + const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"activation_shortcut"; + const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; + const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag"; +} + +// The PowerToy name that will be shown in the settings. +const static wchar_t* MODULE_NAME = L"CursorWrap"; +// Add a description that will we shown in the module settings page. +const static wchar_t* MODULE_DESC = L""; + +// Mouse hook data structure +struct MonitorInfo +{ + RECT rect; + bool isPrimary; + int monitorId; // Add monitor ID for easier debugging +}; + +// Add structure for logical monitor grid position +struct LogicalPosition +{ + int row; + int col; + bool isValid; +}; + +// Add monitor topology helper +struct MonitorTopology +{ + std::vector> grid; // 3x3 grid of monitors + std::map monitorToPosition; + std::map, HMONITOR> positionToMonitor; + + void Initialize(const std::vector& monitors); + LogicalPosition GetPosition(HMONITOR monitor) const; + HMONITOR GetMonitorAt(int row, int col) const; + HMONITOR FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const; +}; + +// Forward declaration +class CursorWrap; + +// Global instance pointer for the mouse hook +static CursorWrap* g_cursorWrapInstance = nullptr; + +// Implement the PowerToy Module Interface and all the required methods. +class CursorWrap : public PowertoyModuleIface +{ +private: + // The PowerToy state. + bool m_enabled = false; + bool m_autoActivate = false; + bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag + + // Mouse hook + HHOOK m_mouseHook = nullptr; + std::atomic m_hookActive{ false }; + + // Monitor information + std::vector m_monitors; + MonitorTopology m_topology; + + // Hotkey + Hotkey m_activationHotkey{}; + +public: + // Constructor + CursorWrap() + { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::cursorWrapLoggerName); + init_settings(); + UpdateMonitorInfo(); + g_cursorWrapInstance = this; // Set global instance pointer + }; + + // Destroy the powertoy and free memory + virtual void destroy() override + { + StopMouseHook(); + g_cursorWrapInstance = nullptr; // Clear global instance pointer + delete this; + } + + // Return the localized display name of the powertoy + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + // Return the non localized key of the powertoy, this will be cached by the runner + virtual const wchar_t* get_key() override + { + return MODULE_NAME; + } + + // Return the configured status for the gpo policy for the module + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredCursorWrapEnabledValue(); + } + + // Return JSON with the configuration options. + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + + PowerToysSettings::Settings settings(hinstance, get_name()); + + settings.set_description(IDS_CURSORWRAP_NAME); + settings.set_icon_key(L"pt-cursor-wrap"); + + // Create HotkeyObject from the Hotkey struct for the settings + auto hotkey_object = PowerToysSettings::HotkeyObject::from_settings( + m_activationHotkey.win, + m_activationHotkey.ctrl, + m_activationHotkey.alt, + m_activationHotkey.shift, + m_activationHotkey.key); + + settings.add_hotkey(JSON_KEY_ACTIVATION_SHORTCUT, IDS_CURSORWRAP_NAME, hotkey_object); + settings.add_bool_toggle(JSON_KEY_AUTO_ACTIVATE, IDS_CURSORWRAP_NAME, m_autoActivate); + settings.add_bool_toggle(JSON_KEY_DISABLE_WRAP_DURING_DRAG, IDS_CURSORWRAP_NAME, m_disableWrapDuringDrag); + + return settings.serialize_to_buffer(buffer, buffer_size); + } + + // Signal from the Settings editor to call a custom action. + // This can be used to spawn more complex editors. + virtual void call_custom_action(const wchar_t* /*action*/) override {} + + // Called by the runner to pass the updated settings values as a serialized JSON. + virtual void set_config(const wchar_t* config) override + { + try + { + // Parse the input JSON string. + PowerToysSettings::PowerToyValues values = + PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + + parse_settings(values); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to parse CursorWrap settings json."); + } + } + + // Enable the powertoy + virtual void enable() + { + m_enabled = true; + Trace::EnableCursorWrap(true); + + // Always start the mouse hook when the module is enabled + // This ensures cursor wrapping is active immediately after enabling + StartMouseHook(); + Logger::info("CursorWrap enabled - mouse hook started"); + } + + // Disable the powertoy + virtual void disable() + { + m_enabled = false; + Trace::EnableCursorWrap(false); + StopMouseHook(); + Logger::info("CursorWrap disabled - mouse hook stopped"); + } + + // Returns if the powertoys is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + // Returns whether the PowerToys should be enabled by default + virtual bool is_enabled_by_default() const override + { + return false; + } + + // Legacy hotkey support + virtual size_t get_hotkeys(Hotkey* buffer, size_t buffer_size) override + { + if (buffer && buffer_size >= 1) + { + buffer[0] = m_activationHotkey; + } + return 1; + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (!m_enabled || hotkeyId != 0) + { + return false; + } + + // Toggle cursor wrapping + if (m_hookActive) + { + StopMouseHook(); + } + else + { + StartMouseHook(); +#ifdef _DEBUG + // Run comprehensive tests when hook is started in debug builds + RunComprehensiveTests(); +#endif + } + + return true; + } + +private: + // Load the settings file. + void init_settings() + { + try + { + // Load and parse the settings file for this PowerToy. + PowerToysSettings::PowerToyValues settings = + PowerToysSettings::PowerToyValues::load_from_settings_file(CursorWrap::get_key()); + parse_settings(settings); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to load the CursorWrap settings json from file."); + } + } + + void parse_settings(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + // Parse activation HotKey + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); + + m_activationHotkey.win = hotkey.win_pressed(); + m_activationHotkey.ctrl = hotkey.ctrl_pressed(); + m_activationHotkey.shift = hotkey.shift_pressed(); + m_activationHotkey.alt = hotkey.alt_pressed(); + m_activationHotkey.key = static_cast(hotkey.get_code()); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap activation shortcut"); + } + + try + { + // Parse auto activate + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE); + m_autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap auto activate from settings. Will use default value"); + } + + try + { + // Parse disable wrap during drag + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_DISABLE_WRAP_DURING_DRAG)) + { + auto disableDragObject = propertiesObject.GetNamedObject(JSON_KEY_DISABLE_WRAP_DURING_DRAG); + m_disableWrapDuringDrag = disableDragObject.GetNamedBoolean(JSON_KEY_VALUE); + } + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap disable wrap during drag from settings. Will use default value (true)"); + } + } + else + { + Logger::info("CursorWrap settings are empty"); + } + + // Set default hotkey if not configured + if (m_activationHotkey.key == 0) + { + m_activationHotkey.win = true; + m_activationHotkey.alt = true; + m_activationHotkey.ctrl = false; + m_activationHotkey.shift = false; + m_activationHotkey.key = 'U'; // Win+Alt+U + } + } + + void UpdateMonitorInfo() + { + m_monitors.clear(); + + EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) -> BOOL { + auto* self = reinterpret_cast(lParam); + + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(hMonitor, &mi)) + { + MonitorInfo info{}; + info.rect = mi.rcMonitor; + info.isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0; + info.monitorId = static_cast(self->m_monitors.size()); + self->m_monitors.push_back(info); + } + + return TRUE; + }, reinterpret_cast(this)); + + // Initialize monitor topology + m_topology.Initialize(m_monitors); + } + + void StartMouseHook() + { + if (m_mouseHook || m_hookActive) + { + Logger::info("CursorWrap mouse hook already active"); + return; + } + + UpdateMonitorInfo(); + + m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, GetModuleHandle(nullptr), 0); + if (m_mouseHook) + { + m_hookActive = true; + Logger::info("CursorWrap mouse hook started successfully"); +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Hook installed"); +#endif + } + else + { + DWORD error = GetLastError(); + Logger::error(L"Failed to install CursorWrap mouse hook, error: {}", error); + } + } + + void StopMouseHook() + { + if (m_mouseHook) + { + UnhookWindowsHookEx(m_mouseHook); + m_mouseHook = nullptr; + m_hookActive = false; + Logger::info("CursorWrap mouse hook stopped"); +#ifdef _DEBUG + Logger::info("CursorWrap DEBUG: Mouse hook stopped"); +#endif + } + } + + static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) + { + if (nCode >= 0 && wParam == WM_MOUSEMOVE) + { + auto* pMouseStruct = reinterpret_cast(lParam); + POINT currentPos = { pMouseStruct->pt.x, pMouseStruct->pt.y }; + + if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive) + { + POINT newPos = g_cursorWrapInstance->HandleMouseMove(currentPos); + if (newPos.x != currentPos.x || newPos.y != currentPos.y) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Wrapping cursor from ({}, {}) to ({}, {})", + currentPos.x, currentPos.y, newPos.x, newPos.y); +#endif + SetCursorPos(newPos.x, newPos.y); + return 1; // Suppress the original message + } + } + } + + return CallNextHookEx(nullptr, nCode, wParam, lParam); + } + + // *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC *** + // Implements vertical scrolling to bottom/top of vertical stack as requested + POINT HandleMouseMove(const POINT& currentPos) + { + POINT newPos = currentPos; + + // Check if we should skip wrapping during drag if the setting is enabled + if (m_disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000)) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Left mouse button is down and disable_wrap_during_drag is enabled - skipping wrap"); +#endif + return currentPos; // Return unchanged position (no wrapping) + } + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE START ======="); + Logger::info(L"CursorWrap DEBUG: Input position ({}, {})", currentPos.x, currentPos.y); +#endif + + // Find which monitor the cursor is currently on + HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); + MONITORINFO currentMonitorInfo{}; + currentMonitorInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(currentMonitor, ¤tMonitorInfo); + + LogicalPosition currentLogicalPos = m_topology.GetPosition(currentMonitor); + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Current monitor bounds: Left={}, Top={}, Right={}, Bottom={}", + currentMonitorInfo.rcMonitor.left, currentMonitorInfo.rcMonitor.top, + currentMonitorInfo.rcMonitor.right, currentMonitorInfo.rcMonitor.bottom); + Logger::info(L"CursorWrap DEBUG: Logical position: Row={}, Col={}, Valid={}", + currentLogicalPos.row, currentLogicalPos.col, currentLogicalPos.isValid); +#endif + + bool wrapped = false; + + // *** VERTICAL WRAPPING LOGIC - CONFIRMED WORKING *** + // Move to bottom of vertical stack when hitting top edge + if (currentPos.y <= currentMonitorInfo.rcMonitor.top) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED ======="); +#endif + + // Find the bottom-most monitor in the vertical stack (same column) + HMONITOR bottomMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search down from current position to find the bottom-most monitor in same column + for (int row = 2; row >= 0; row--) { // Start from bottom and work up + HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); + if (candidateMonitor) { + bottomMonitor = candidateMonitor; + break; // Found the bottom-most monitor + } + } + } + + if (bottomMonitor && bottomMonitor != currentMonitor) { + // *** MOVE TO BOTTOM OF VERTICAL STACK *** + MONITORINFO bottomInfo{}; + bottomInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(bottomMonitor, &bottomInfo); + + // Calculate relative X position to maintain cursor X alignment + double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / + (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); + + int targetWidth = bottomInfo.rcMonitor.right - bottomInfo.rcMonitor.left; + newPos.x = bottomInfo.rcMonitor.left + static_cast(relativeX * targetWidth); + newPos.y = bottomInfo.rcMonitor.bottom - 1; // Bottom edge of bottom monitor + + // Clamp X to target monitor bounds + newPos.x = max(bottomInfo.rcMonitor.left, min(newPos.x, bottomInfo.rcMonitor.right - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to bottom of vertical stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.y = currentMonitorInfo.rcMonitor.bottom - 1; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + else if (currentPos.y >= currentMonitorInfo.rcMonitor.bottom - 1) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: BOTTOM EDGE DETECTED ======="); +#endif + + // Find the top-most monitor in the vertical stack (same column) + HMONITOR topMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search up from current position to find the top-most monitor in same column + for (int row = 0; row <= 2; row++) { // Start from top and work down + HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); + if (candidateMonitor) { + topMonitor = candidateMonitor; + break; // Found the top-most monitor + } + } + } + + if (topMonitor && topMonitor != currentMonitor) { + // *** MOVE TO TOP OF VERTICAL STACK *** + MONITORINFO topInfo{}; + topInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(topMonitor, &topInfo); + + // Calculate relative X position to maintain cursor X alignment + double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / + (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); + + int targetWidth = topInfo.rcMonitor.right - topInfo.rcMonitor.left; + newPos.x = topInfo.rcMonitor.left + static_cast(relativeX * targetWidth); + newPos.y = topInfo.rcMonitor.top; // Top edge of top monitor + + // Clamp X to target monitor bounds + newPos.x = max(topInfo.rcMonitor.left, min(newPos.x, topInfo.rcMonitor.right - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to top of vertical stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.y = currentMonitorInfo.rcMonitor.top; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + + // *** FIXED HORIZONTAL WRAPPING LOGIC *** + // Move to opposite end of horizontal stack when hitting left/right edge + // Only handle horizontal wrapping if we haven't already wrapped vertically + if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED ======="); +#endif + + // Find the right-most monitor in the horizontal stack (same row) + HMONITOR rightMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search right from current position to find the right-most monitor in same row + for (int col = 2; col >= 0; col--) { // Start from right and work left + HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); + if (candidateMonitor) { + rightMonitor = candidateMonitor; + break; // Found the right-most monitor + } + } + } + + if (rightMonitor && rightMonitor != currentMonitor) { + // *** MOVE TO RIGHT END OF HORIZONTAL STACK *** + MONITORINFO rightInfo{}; + rightInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(rightMonitor, &rightInfo); + + // Calculate relative Y position to maintain cursor Y alignment + double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / + (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); + + int targetHeight = rightInfo.rcMonitor.bottom - rightInfo.rcMonitor.top; + newPos.y = rightInfo.rcMonitor.top + static_cast(relativeY * targetHeight); + newPos.x = rightInfo.rcMonitor.right - 1; // Right edge of right monitor + + // Clamp Y to target monitor bounds + newPos.y = max(rightInfo.rcMonitor.top, min(newPos.y, rightInfo.rcMonitor.bottom - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to right end of horizontal stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.x = currentMonitorInfo.rcMonitor.right - 1; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + else if (!wrapped && currentPos.x >= currentMonitorInfo.rcMonitor.right - 1) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: RIGHT EDGE DETECTED ======="); +#endif + + // Find the left-most monitor in the horizontal stack (same row) + HMONITOR leftMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search left from current position to find the left-most monitor in same row + for (int col = 0; col <= 2; col++) { // Start from left and work right + HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); + if (candidateMonitor) { + leftMonitor = candidateMonitor; + break; // Found the left-most monitor + } + } + } + + if (leftMonitor && leftMonitor != currentMonitor) { + // *** MOVE TO LEFT END OF HORIZONTAL STACK *** + MONITORINFO leftInfo{}; + leftInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(leftMonitor, &leftInfo); + + // Calculate relative Y position to maintain cursor Y alignment + double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / + (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); + + int targetHeight = leftInfo.rcMonitor.bottom - leftInfo.rcMonitor.top; + newPos.y = leftInfo.rcMonitor.top + static_cast(relativeY * targetHeight); + newPos.x = leftInfo.rcMonitor.left; // Left edge of left monitor + + // Clamp Y to target monitor bounds + newPos.y = max(leftInfo.rcMonitor.top, min(newPos.y, leftInfo.rcMonitor.bottom - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to left end of horizontal stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.x = currentMonitorInfo.rcMonitor.left; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + +#ifdef _DEBUG + if (wrapped) + { + Logger::info(L"CursorWrap DEBUG: ======= WRAP RESULT ======="); + Logger::info(L"CursorWrap DEBUG: Original: ({}, {}) -> New: ({}, {})", + currentPos.x, currentPos.y, newPos.x, newPos.y); + } + else + { + Logger::info(L"CursorWrap DEBUG: No wrapping performed - cursor not at edge"); + } + Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE END ======="); +#endif + + return newPos; + } + + // Add test method for monitor topology validation + void RunMonitorTopologyTests() + { +#ifdef _DEBUG + Logger::info(L"CursorWrap: Running monitor topology tests..."); + + // Test all 9 possible monitor positions in 3x3 grid + const char* gridNames[3][3] = { + {"TL", "TC", "TR"}, // Top-Left, Top-Center, Top-Right + {"ML", "MC", "MR"}, // Middle-Left, Middle-Center, Middle-Right + {"BL", "BC", "BR"} // Bottom-Left, Bottom-Center, Bottom-Right + }; + + for (int row = 0; row < 3; row++) + { + for (int col = 0; col < 3; col++) + { + HMONITOR monitor = m_topology.GetMonitorAt(row, col); + if (monitor) + { + std::string gridName(gridNames[row][col]); + std::wstring wGridName(gridName.begin(), gridName.end()); + Logger::info(L"CursorWrap TEST: Monitor at [{}][{}] ({}) exists", + row, col, wGridName.c_str()); + + // Test adjacent monitor finding + HMONITOR up = m_topology.FindAdjacentMonitor(monitor, -1, 0); + HMONITOR down = m_topology.FindAdjacentMonitor(monitor, 1, 0); + HMONITOR left = m_topology.FindAdjacentMonitor(monitor, 0, -1); + HMONITOR right = m_topology.FindAdjacentMonitor(monitor, 0, 1); + + Logger::info(L"CursorWrap TEST: Adjacent monitors - Up: {}, Down: {}, Left: {}, Right: {}", + up ? L"YES" : L"NO", down ? L"YES" : L"NO", + left ? L"YES" : L"NO", right ? L"YES" : L"NO"); + } + } + } + + Logger::info(L"CursorWrap: Monitor topology tests completed."); +#endif + } + + // Add method to trigger test suite (can be called via hotkey in debug builds) + void RunComprehensiveTests() + { +#ifdef _DEBUG + RunMonitorTopologyTests(); + + // Test cursor wrapping scenarios + Logger::info(L"CursorWrap: Testing cursor wrapping scenarios..."); + + // Simulate cursor positions at each monitor edge and verify expected behavior + for (const auto& monitor : m_monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + LogicalPosition pos = m_topology.GetPosition(hMonitor); + + if (pos.isValid) + { + Logger::info(L"CursorWrap TEST: Testing monitor at position [{}][{}]", pos.row, pos.col); + + // Test top edge + POINT topEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.top}; + POINT newPos = HandleMouseMove(topEdge); + Logger::info(L"CursorWrap TEST: Top edge ({}, {}) -> ({}, {})", + topEdge.x, topEdge.y, newPos.x, newPos.y); + + // Test bottom edge + POINT bottomEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.bottom - 1}; + newPos = HandleMouseMove(bottomEdge); + Logger::info(L"CursorWrap TEST: Bottom edge ({}, {}) -> ({}, {})", + bottomEdge.x, bottomEdge.y, newPos.x, newPos.y); + + // Test left edge + POINT leftEdge = {monitor.rect.left, (monitor.rect.top + monitor.rect.bottom) / 2}; + newPos = HandleMouseMove(leftEdge); + Logger::info(L"CursorWrap TEST: Left edge ({}, {}) -> ({}, {})", + leftEdge.x, leftEdge.y, newPos.x, newPos.y); + + // Test right edge + POINT rightEdge = {monitor.rect.right - 1, (monitor.rect.top + monitor.rect.bottom) / 2}; + newPos = HandleMouseMove(rightEdge); + Logger::info(L"CursorWrap TEST: Right edge ({}, {}) -> ({}, {})", + rightEdge.x, rightEdge.y, newPos.x, newPos.y); + } + } + + Logger::info(L"CursorWrap: Comprehensive tests completed."); +#endif + } +}; + +// Implementation of MonitorTopology methods +void MonitorTopology::Initialize(const std::vector& monitors) +{ + // Clear existing data + grid.assign(3, std::vector(3, nullptr)); + monitorToPosition.clear(); + positionToMonitor.clear(); + + if (monitors.empty()) return; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION START ======="); + Logger::info(L"CursorWrap DEBUG: Initializing topology for {} monitors", monitors.size()); + for (const auto& monitor : monitors) + { + Logger::info(L"CursorWrap DEBUG: Monitor {}: bounds=({},{},{},{}), isPrimary={}", + monitor.monitorId, monitor.rect.left, monitor.rect.top, + monitor.rect.right, monitor.rect.bottom, monitor.isPrimary); + } +#endif + + // Special handling for 2 monitors - use physical position, not discovery order + if (monitors.size() == 2) + { + // Determine if arrangement is horizontal or vertical by comparing centers + POINT center0 = {(monitors[0].rect.left + monitors[0].rect.right) / 2, + (monitors[0].rect.top + monitors[0].rect.bottom) / 2}; + POINT center1 = {(monitors[1].rect.left + monitors[1].rect.right) / 2, + (monitors[1].rect.top + monitors[1].rect.bottom) / 2}; + + int xDiff = abs(center0.x - center1.x); + int yDiff = abs(center0.y - center1.y); + + bool isHorizontal = xDiff > yDiff; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor centers: M0=({}, {}), M1=({}, {})", + center0.x, center0.y, center1.x, center1.y); + Logger::info(L"CursorWrap DEBUG: Differences: X={}, Y={}, IsHorizontal={}", + xDiff, yDiff, isHorizontal); +#endif + + if (isHorizontal) + { + // Horizontal arrangement - place in middle row [1,0] and [1,2] + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + POINT center = {(monitor.rect.left + monitor.rect.right) / 2, + (monitor.rect.top + monitor.rect.bottom) / 2}; + + int row = 1; // Middle row + int col = (center.x < (center0.x + center1.x) / 2) ? 0 : 2; // Left or right based on center + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} (horizontal) placed at grid[{}][{}]", + monitor.monitorId, row, col); +#endif + } + } + else + { + // *** VERTICAL ARRANGEMENT - CRITICAL LOGIC *** + // Sort monitors by Y coordinate to determine vertical order + std::vector> sortedMonitors; + for (int i = 0; i < 2; i++) { + sortedMonitors.push_back({i, monitors[i]}); + } + + // Sort by Y coordinate (top to bottom) + std::sort(sortedMonitors.begin(), sortedMonitors.end(), + [](const std::pair& a, const std::pair& b) { + int centerA = (a.second.rect.top + a.second.rect.bottom) / 2; + int centerB = (b.second.rect.top + b.second.rect.bottom) / 2; + return centerA < centerB; // Top first + }); + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL ARRANGEMENT DETECTED"); + Logger::info(L"CursorWrap DEBUG: Top monitor: ID={}, Y-center={}", + sortedMonitors[0].second.monitorId, + (sortedMonitors[0].second.rect.top + sortedMonitors[0].second.rect.bottom) / 2); + Logger::info(L"CursorWrap DEBUG: Bottom monitor: ID={}, Y-center={}", + sortedMonitors[1].second.monitorId, + (sortedMonitors[1].second.rect.top + sortedMonitors[1].second.rect.bottom) / 2); +#endif + + // Place monitors in grid based on sorted order + for (int i = 0; i < 2; i++) { + const auto& monitorPair = sortedMonitors[i]; + const auto& monitor = monitorPair.second; + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + + int col = 1; // Middle column for vertical arrangement + int row = (i == 0) ? 0 : 2; // Top monitor at row 0, bottom at row 2 + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} (vertical) placed at grid[{}][{}] - {} position", + monitor.monitorId, row, col, (i == 0) ? L"TOP" : L"BOTTOM"); +#endif + } + } + } + else + { + // For more than 2 monitors, use the general algorithm + RECT totalBounds = monitors[0].rect; + for (const auto& monitor : monitors) + { + totalBounds.left = min(totalBounds.left, monitor.rect.left); + totalBounds.top = min(totalBounds.top, monitor.rect.top); + totalBounds.right = max(totalBounds.right, monitor.rect.right); + totalBounds.bottom = max(totalBounds.bottom, monitor.rect.bottom); + } + + int totalWidth = totalBounds.right - totalBounds.left; + int totalHeight = totalBounds.bottom - totalBounds.top; + int gridWidth = max(1, totalWidth / 3); + int gridHeight = max(1, totalHeight / 3); + + // Place monitors in the 3x3 grid based on their center points + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + + // Calculate center point of monitor + int centerX = (monitor.rect.left + monitor.rect.right) / 2; + int centerY = (monitor.rect.top + monitor.rect.bottom) / 2; + + // Map to grid position + int col = (centerX - totalBounds.left) / gridWidth; + int row = (centerY - totalBounds.top) / gridHeight; + + // Ensure we stay within bounds + col = max(0, min(2, col)); + row = max(0, min(2, row)); + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}], center=({}, {})", + monitor.monitorId, row, col, centerX, centerY); +#endif + } + } + +#ifdef _DEBUG + // *** CRITICAL: Print topology map using OutputDebugString for debug builds *** + Logger::info(L"CursorWrap DEBUG: ======= FINAL TOPOLOGY MAP ======="); + OutputDebugStringA("CursorWrap TOPOLOGY MAP:\n"); + for (int r = 0; r < 3; r++) + { + std::string rowStr = " "; + for (int c = 0; c < 3; c++) + { + if (grid[r][c]) + { + // Find monitor ID for this handle + int monitorId = -1; + for (const auto& monitor : monitors) + { + HMONITOR handle = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + if (handle == grid[r][c]) + { + monitorId = monitor.monitorId + 1; // Convert to 1-based for display + break; + } + } + rowStr += std::to_string(monitorId) + " "; + } + else + { + rowStr += ". "; + } + } + rowStr += "\n"; + OutputDebugStringA(rowStr.c_str()); + + // Also log to PowerToys logger + std::wstring wRowStr(rowStr.begin(), rowStr.end()); + Logger::info(wRowStr.c_str()); + } + OutputDebugStringA("======= END TOPOLOGY MAP =======\n"); + + // Additional validation logging + Logger::info(L"CursorWrap DEBUG: ======= GRID POSITION VALIDATION ======="); + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + LogicalPosition pos = GetPosition(hMonitor); + if (pos.isValid) + { + Logger::info(L"CursorWrap DEBUG: Monitor {} -> grid[{}][{}]", monitor.monitorId, pos.row, pos.col); + OutputDebugStringA(("Monitor " + std::to_string(monitor.monitorId) + " -> grid[" + std::to_string(pos.row) + "][" + std::to_string(pos.col) + "]\n").c_str()); + + // Test adjacent finding + HMONITOR up = FindAdjacentMonitor(hMonitor, -1, 0); + HMONITOR down = FindAdjacentMonitor(hMonitor, 1, 0); + HMONITOR left = FindAdjacentMonitor(hMonitor, 0, -1); + HMONITOR right = FindAdjacentMonitor(hMonitor, 0, 1); + + Logger::info(L"CursorWrap DEBUG: Monitor {} adjacents - Up: {}, Down: {}, Left: {}, Right: {}", + monitor.monitorId, up ? L"YES" : L"NO", down ? L"YES" : L"NO", + left ? L"YES" : L"NO", right ? L"YES" : L"NO"); + } + } + Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION COMPLETE ======="); +#endif +} + +LogicalPosition MonitorTopology::GetPosition(HMONITOR monitor) const +{ + auto it = monitorToPosition.find(monitor); + if (it != monitorToPosition.end()) + { + return it->second; + } + return {-1, -1, false}; +} + +HMONITOR MonitorTopology::GetMonitorAt(int row, int col) const +{ + if (row >= 0 && row < 3 && col >= 0 && col < 3) + { + return grid[row][col]; + } + return nullptr; +} + +HMONITOR MonitorTopology::FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const +{ + LogicalPosition currentPos = GetPosition(current); + if (!currentPos.isValid) return nullptr; + + int newRow = currentPos.row + deltaRow; + int newCol = currentPos.col + deltaCol; + + return GetMonitorAt(newRow, newCol); +} + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new CursorWrap(); +} diff --git a/src/modules/MouseUtils/CursorWrap/packages.config b/src/modules/MouseUtils/CursorWrap/packages.config new file mode 100644 index 0000000000..2c5d71ae86 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.cpp b/src/modules/MouseUtils/CursorWrap/pch.cpp new file mode 100644 index 0000000000..17305716aa --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.h b/src/modules/MouseUtils/CursorWrap/pch.h new file mode 100644 index 0000000000..86f11c99ba --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.h @@ -0,0 +1,13 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#include + +#include +#include +#include + +// Note: Common includes moved to individual source files due to include path issues +// #include +// #include +// #include \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/resource.h b/src/modules/MouseUtils/CursorWrap/resource.h new file mode 100644 index 0000000000..9b49c0e3cc --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/resource.h @@ -0,0 +1,4 @@ +#pragma once + +#define IDS_CURSORWRAP_NAME 101 +#define IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG 102 diff --git a/src/modules/MouseUtils/CursorWrap/trace.cpp b/src/modules/MouseUtils/CursorWrap/trace.cpp new file mode 100644 index 0000000000..ebfe32c23c --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.cpp @@ -0,0 +1,31 @@ +#include "pch.h" +#include "trace.h" + +#include "../../../../common/Telemetry/TraceBase.h" + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::EnableCursorWrap(const bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "CursorWrap_EnableCursorWrap", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/trace.h b/src/modules/MouseUtils/CursorWrap/trace.h new file mode 100644 index 0000000000..b2f6a9a8eb --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +class Trace : public telemetry::TraceBase +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void EnableCursorWrap(const bool enabled) noexcept; +}; \ No newline at end of file diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj index 88eb348610..b8ca543227 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj @@ -64,6 +64,7 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + MultiThreadedDebug stdcpplatest @@ -81,6 +82,7 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + MultiThreaded stdcpplatest diff --git a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj index b62315f0dc..f14bbb391d 100644 --- a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj +++ b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj @@ -48,6 +48,7 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + MultiThreadedDebug stdcpplatest @@ -65,6 +66,7 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + MultiThreaded stdcpplatest diff --git a/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj b/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj index a353a48c61..8b68ff2371 100644 --- a/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj +++ b/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj @@ -48,6 +48,7 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + MultiThreadedDebug stdcpplatest @@ -65,6 +66,7 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + MultiThreaded stdcpplatest diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj index 61055d7fc5..5503a1225a 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj +++ b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj @@ -49,6 +49,7 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + MultiThreadedDebug stdcpplatest @@ -66,6 +67,7 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + MultiThreaded stdcpplatest diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj index 3d8e044b0b..e762d719f6 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj @@ -46,7 +46,7 @@ _DEBUG;NEWPLUSSHELLEXTENSIONWIN10_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true Use - ..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories);..\NewShellExtensionContextMenu + ..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories);..\NewShellExtensionContextMenu;$(MSBuildThisFileDirectory) false stdcpplatest @@ -67,7 +67,7 @@ NDEBUG;NEWPLUSSHELLEXTENSIONWIN10_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true Use - ..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories);..\NewShellExtensionContextMenu + ..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories);..\NewShellExtensionContextMenu;$(MSBuildThisFileDirectory) false stdcpplatest diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj index 2e7f8a44a2..4032836f08 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj @@ -67,6 +67,8 @@ false dll.def runtimeobject.lib;$(CoreLibraryDependencies) + + del $(OutDir)\NewPlusPackage.msix /q @@ -98,6 +100,8 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv false dll.def runtimeobject.lib;$(CoreLibraryDependencies) + + del $(OutDir)\NewPlusPackage.msix /q diff --git a/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj b/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj index 6d287a1126..3f19cea2e2 100644 --- a/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj +++ b/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj @@ -29,6 +29,7 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true + MultiThreadedDebug true @@ -39,6 +40,7 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false + MultiThreaded true true diff --git a/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj b/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj index e448d2b75d..b3a540a32e 100644 --- a/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj +++ b/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj @@ -29,6 +29,7 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true + MultiThreadedDebug true @@ -39,6 +40,7 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false + MultiThreaded true true diff --git a/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj b/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj index 4555f6257b..2451be2470 100644 --- a/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj +++ b/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj @@ -29,6 +29,7 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true + MultiThreadedDebug true @@ -39,6 +40,7 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false + MultiThreaded true true diff --git a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp new file mode 100644 index 0000000000..22e2079f71 --- /dev/null +++ b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp @@ -0,0 +1,548 @@ +//============================================================================== +// +// Zoomit +// Sysinternals - www.sysinternals.com +// +// GIF recording support using Windows Imaging Component (WIC) +// +//============================================================================== +#include "pch.h" +#include "GifRecordingSession.h" +#include "CaptureFrameWait.h" +#include + +extern DWORD g_RecordScaling; + +namespace winrt +{ + using namespace Windows::Foundation; + using namespace Windows::Graphics; + using namespace Windows::Graphics::Capture; + using namespace Windows::Graphics::DirectX; + using namespace Windows::Graphics::DirectX::Direct3D11; + using namespace Windows::Storage; + using namespace Windows::UI::Composition; +} + +namespace util +{ + using namespace robmikh::common::uwp; +} + +const float CLEAR_COLOR[] = { 0.0f, 0.0f, 0.0f, 1.0f }; + +int32_t EnsureEvenGif(int32_t value) +{ + if (value % 2 == 0) + { + return value; + } + else + { + return value + 1; + } +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::GifRecordingSession +// +//---------------------------------------------------------------------------- +GifRecordingSession::GifRecordingSession( + winrt::IDirect3DDevice const& device, + winrt::GraphicsCaptureItem const& item, + RECT const cropRect, + uint32_t frameRate, + winrt::Streams::IRandomAccessStream const& stream) +{ + m_device = device; + m_d3dDevice = GetDXGIInterfaceFromObject(m_device); + m_d3dDevice->GetImmediateContext(m_d3dContext.put()); + m_item = item; + m_frameRate = frameRate; + m_stream = stream; + + auto itemSize = item.Size(); + auto inputWidth = EnsureEvenGif(itemSize.Width); + auto inputHeight = EnsureEvenGif(itemSize.Height); + m_frameWait = std::make_shared(m_device, m_item, winrt::SizeInt32{ inputWidth, inputHeight }); + auto weakPointer{ std::weak_ptr{ m_frameWait } }; + m_itemClosed = item.Closed(winrt::auto_revoke, [weakPointer](auto&, auto&) + { + auto sharedPointer{ weakPointer.lock() }; + if (sharedPointer) + { + sharedPointer->StopCapture(); + } + }); + + // Get crop dimension + if ((cropRect.right - cropRect.left) != 0) + { + m_rcCrop = cropRect; + m_frameWait->ShowCaptureBorder(false); + } + else + { + m_rcCrop.left = 0; + m_rcCrop.top = 0; + m_rcCrop.right = inputWidth; + m_rcCrop.bottom = inputHeight; + } + + // Apply scaling + constexpr int c_minimumSize = 34; + auto scaledWidth = MulDiv(m_rcCrop.right - m_rcCrop.left, g_RecordScaling, 100); + auto scaledHeight = MulDiv(m_rcCrop.bottom - m_rcCrop.top, g_RecordScaling, 100); + m_width = scaledWidth; + m_height = scaledHeight; + if (m_width < c_minimumSize) + { + m_width = c_minimumSize; + m_height = MulDiv(m_height, m_width, scaledWidth); + } + if (m_height < c_minimumSize) + { + m_height = c_minimumSize; + m_width = MulDiv(m_width, m_height, scaledHeight); + } + if (m_width > inputWidth) + { + m_width = inputWidth; + m_height = c_minimumSize, MulDiv(m_height, scaledWidth, m_width); + } + if (m_height > inputHeight) + { + m_height = inputHeight; + m_width = c_minimumSize, MulDiv(m_width, scaledHeight, m_height); + } + m_width = EnsureEvenGif(m_width); + m_height = EnsureEvenGif(m_height); + + m_frameDelay = (frameRate > 0) ? (100 / frameRate) : 15; + + // Initialize WIC + winrt::check_hresult(CoCreateInstance( + CLSID_WICImagingFactory, + nullptr, + CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(m_wicFactory.put()))); + + // Create WIC stream from IRandomAccessStream + winrt::check_hresult(m_wicFactory->CreateStream(m_wicStream.put())); + + // Get the IStream from the IRandomAccessStream + winrt::com_ptr streamInterop; + winrt::check_hresult(CreateStreamOverRandomAccessStream( + winrt::get_unknown(stream), + IID_PPV_ARGS(streamInterop.put()))); + winrt::check_hresult(m_wicStream->InitializeFromIStream(streamInterop.get())); + + // Create GIF encoder + winrt::check_hresult(m_wicFactory->CreateEncoder( + GUID_ContainerFormatGif, + nullptr, + m_gifEncoder.put())); + + winrt::check_hresult(m_gifEncoder->Initialize(m_wicStream.get(), WICBitmapEncoderNoCache)); + + // Set global GIF metadata for looping (NETSCAPE2.0 application extension) + try + { + winrt::com_ptr encoderMetadataWriter; + if (SUCCEEDED(m_gifEncoder->GetMetadataQueryWriter(encoderMetadataWriter.put())) && encoderMetadataWriter) + { + OutputDebugStringW(L"Setting NETSCAPE2.0 looping extension on encoder...\n"); + + // Set application extension + PROPVARIANT propValue; + PropVariantInit(&propValue); + propValue.vt = VT_UI1 | VT_VECTOR; + propValue.caub.cElems = 11; + propValue.caub.pElems = static_cast(CoTaskMemAlloc(11)); + if (propValue.caub.pElems != nullptr) + { + memcpy(propValue.caub.pElems, "NETSCAPE2.0", 11); + HRESULT hr = encoderMetadataWriter->SetMetadataByName(L"/appext/application", &propValue); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"Encoder application extension set successfully\n"); + } + else + { + OutputDebugStringW(L"Failed to set encoder application extension\n"); + } + PropVariantClear(&propValue); + + // Set loop count (0 = infinite) + PropVariantInit(&propValue); + propValue.vt = VT_UI1 | VT_VECTOR; + propValue.caub.cElems = 5; + propValue.caub.pElems = static_cast(CoTaskMemAlloc(5)); + if (propValue.caub.pElems != nullptr) + { + propValue.caub.pElems[0] = 3; + propValue.caub.pElems[1] = 1; + propValue.caub.pElems[2] = 0; + propValue.caub.pElems[3] = 0; + propValue.caub.pElems[4] = 0; + hr = encoderMetadataWriter->SetMetadataByName(L"/appext/data", &propValue); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"Encoder loop count set successfully\n"); + } + else + { + OutputDebugStringW(L"Failed to set encoder loop count\n"); + } + PropVariantClear(&propValue); + } + } + } + else + { + OutputDebugStringW(L"Failed to get encoder metadata writer\n"); + } + } + catch (...) + { + OutputDebugStringW(L"Warning: Failed to set GIF encoder looping metadata\n"); + } +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::~GifRecordingSession +// +//---------------------------------------------------------------------------- +GifRecordingSession::~GifRecordingSession() +{ + Close(); +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::Create +// +//---------------------------------------------------------------------------- +std::shared_ptr GifRecordingSession::Create( + winrt::IDirect3DDevice const& device, + winrt::GraphicsCaptureItem const& item, + RECT const& crop, + uint32_t frameRate, + winrt::Streams::IRandomAccessStream const& stream) +{ + return std::shared_ptr(new GifRecordingSession(device, item, crop, frameRate, stream)); +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::EncodeFrame +// +//---------------------------------------------------------------------------- +HRESULT GifRecordingSession::EncodeFrame(ID3D11Texture2D* frameTexture) +{ + try + { + // Create a staging texture for CPU access + D3D11_TEXTURE2D_DESC frameDesc; + frameTexture->GetDesc(&frameDesc); + + // GIF encoding with palette generation is VERY slow at high resolutions (4K takes 1 second per frame!) + + UINT targetWidth = frameDesc.Width; + UINT targetHeight = frameDesc.Height; + + if (frameDesc.Width > static_cast(m_width) || frameDesc.Height > static_cast(m_height)) + { + float scaleX = static_cast(m_width) / frameDesc.Width; + float scaleY = static_cast(m_height) / frameDesc.Height; + float scale = min(scaleX, scaleY); + + targetWidth = static_cast(frameDesc.Width * scale); + targetHeight = static_cast(frameDesc.Height * scale); + + // Ensure even dimensions for GIF + targetWidth = (targetWidth / 2) * 2; + targetHeight = (targetHeight / 2) * 2; + } + + D3D11_TEXTURE2D_DESC stagingDesc = frameDesc; + stagingDesc.Usage = D3D11_USAGE_STAGING; + stagingDesc.BindFlags = 0; + stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + stagingDesc.MiscFlags = 0; + + winrt::com_ptr stagingTexture; + winrt::check_hresult(m_d3dDevice->CreateTexture2D(&stagingDesc, nullptr, stagingTexture.put())); + + // Copy the frame to staging texture + m_d3dContext->CopyResource(stagingTexture.get(), frameTexture); + + // Map the staging texture + D3D11_MAPPED_SUBRESOURCE mappedResource; + winrt::check_hresult(m_d3dContext->Map(stagingTexture.get(), 0, D3D11_MAP_READ, 0, &mappedResource)); + + // Create a new frame in the GIF + winrt::com_ptr frameEncode; + winrt::com_ptr propertyBag; + winrt::check_hresult(m_gifEncoder->CreateNewFrame(frameEncode.put(), propertyBag.put())); + + // Initialize the frame encoder with property bag + winrt::check_hresult(frameEncode->Initialize(propertyBag.get())); + + // CRITICAL: For GIF, we MUST set size and pixel format BEFORE WriteSource + // Use target dimensions (may be downsampled) + winrt::check_hresult(frameEncode->SetSize(targetWidth, targetHeight)); + + // Set the pixel format to 8-bit indexed (required for GIF) + WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat8bppIndexed; + winrt::check_hresult(frameEncode->SetPixelFormat(&pixelFormat)); + + // Create a WIC bitmap from the BGRA texture data + winrt::com_ptr sourceBitmap; + winrt::check_hresult(m_wicFactory->CreateBitmapFromMemory( + frameDesc.Width, + frameDesc.Height, + GUID_WICPixelFormat32bppBGRA, + mappedResource.RowPitch, + frameDesc.Height * mappedResource.RowPitch, + static_cast(mappedResource.pData), + sourceBitmap.put())); + + // If we need downsampling, use WIC scaler + winrt::com_ptr finalSource = sourceBitmap; + if (targetWidth != frameDesc.Width || targetHeight != frameDesc.Height) + { + winrt::com_ptr scaler; + winrt::check_hresult(m_wicFactory->CreateBitmapScaler(scaler.put())); + winrt::check_hresult(scaler->Initialize( + sourceBitmap.get(), + targetWidth, + targetHeight, + WICBitmapInterpolationModeHighQualityCubic)); + finalSource = scaler; + + OutputDebugStringW((L"Downsampled from " + std::to_wstring(frameDesc.Width) + L"x" + std::to_wstring(frameDesc.Height) + + L" to " + std::to_wstring(targetWidth) + L"x" + std::to_wstring(targetHeight) + L"\n").c_str()); + } + + // Use WriteSource - WIC will handle the BGRA to 8bpp indexed conversion + winrt::check_hresult(frameEncode->WriteSource(finalSource.get(), nullptr)); + + try + { + winrt::com_ptr frameMetadataWriter; + if (SUCCEEDED(frameEncode->GetMetadataQueryWriter(frameMetadataWriter.put())) && frameMetadataWriter) + { + // Set the frame delay in the metadata (in hundredths of a second) + PROPVARIANT propValue; + PropVariantInit(&propValue); + propValue.vt = VT_UI2; + propValue.uiVal = static_cast(m_frameDelay); + frameMetadataWriter->SetMetadataByName(L"/grctlext/Delay", &propValue); + PropVariantClear(&propValue); + + // Set disposal method (2 = restore to background, needed for animation) + PropVariantInit(&propValue); + propValue.vt = VT_UI1; + propValue.bVal = 2; // Disposal method: restore to background color + frameMetadataWriter->SetMetadataByName(L"/grctlext/Disposal", &propValue); + PropVariantClear(&propValue); + } + } + catch (...) + { + // Metadata setting failed, continue anyway + OutputDebugStringW(L"Warning: Failed to set GIF frame metadata\n"); + } + + // Commit the frame + OutputDebugStringW(L"About to commit frame to encoder...\n"); + winrt::check_hresult(frameEncode->Commit()); + OutputDebugStringW(L"Frame committed successfully\n"); + + // Unmap the staging texture + m_d3dContext->Unmap(stagingTexture.get(), 0); + + // Increment and log frame count + m_frameCount++; + OutputDebugStringW((L"GIF Frame #" + std::to_wstring(m_frameCount) + L" fully encoded and committed\n").c_str()); + + return S_OK; + } + catch (const winrt::hresult_error& error) + { + OutputDebugStringW(error.message().c_str()); + return error.code(); + } +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::StartAsync +// +//---------------------------------------------------------------------------- +winrt::IAsyncAction GifRecordingSession::StartAsync() +{ + auto expected = false; + if (m_isRecording.compare_exchange_strong(expected, true)) + { + auto self = shared_from_this(); + + try + { + // Start capturing frames + auto frameStartTime = std::chrono::high_resolution_clock::now(); + int captureAttempts = 0; + int successfulCaptures = 0; + int duplicatedFrames = 0; + + // Keep track of the last frame to duplicate when needed + winrt::com_ptr lastCroppedTexture; + + while (m_isRecording && !m_closed) + { + captureAttempts++; + auto frame = m_frameWait->TryGetNextFrame(); + + winrt::com_ptr croppedTexture; + + if (frame) + { + successfulCaptures++; + auto contentSize = frame->ContentSize; + auto frameTexture = GetDXGIInterfaceFromObject(frame->FrameTexture); + D3D11_TEXTURE2D_DESC desc = {}; + frameTexture->GetDesc(&desc); + + // Use the smaller of the crop size or content size + auto width = min(m_rcCrop.right - m_rcCrop.left, contentSize.Width); + auto height = min(m_rcCrop.bottom - m_rcCrop.top, contentSize.Height); + + D3D11_TEXTURE2D_DESC croppedDesc = {}; + croppedDesc.Width = width; + croppedDesc.Height = height; + croppedDesc.MipLevels = 1; + croppedDesc.ArraySize = 1; + croppedDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + croppedDesc.SampleDesc.Count = 1; + croppedDesc.Usage = D3D11_USAGE_DEFAULT; + croppedDesc.BindFlags = D3D11_BIND_RENDER_TARGET; + + winrt::check_hresult(m_d3dDevice->CreateTexture2D(&croppedDesc, nullptr, croppedTexture.put())); + + // Set the content region to copy and clamp the coordinates + D3D11_BOX region = {}; + region.left = std::clamp(m_rcCrop.left, static_cast(0), static_cast(desc.Width)); + region.right = std::clamp(m_rcCrop.left + width, static_cast(0), static_cast(desc.Width)); + region.top = std::clamp(m_rcCrop.top, static_cast(0), static_cast(desc.Height)); + region.bottom = std::clamp(m_rcCrop.top + height, static_cast(0), static_cast(desc.Height)); + region.back = 1; + + // Copy the cropped region + m_d3dContext->CopySubresourceRegion( + croppedTexture.get(), + 0, + 0, 0, 0, + frameTexture.get(), + 0, + ®ion); + + // Save this as the last frame for duplication + lastCroppedTexture = croppedTexture; + } + else if (lastCroppedTexture) + { + // No new frame, duplicate the last one + duplicatedFrames++; + croppedTexture = lastCroppedTexture; + } + + // Encode the frame (either new or duplicated) + if (croppedTexture) + { + HRESULT hr = EncodeFrame(croppedTexture.get()); + if (FAILED(hr)) + { + CloseInternal(); + break; + } + } + + // Wait for the next frame interval + co_await winrt::resume_after(std::chrono::milliseconds(1000 / m_frameRate)); + } + + // Commit the GIF encoder + if (m_gifEncoder) + { + auto frameEndTime = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(frameEndTime - frameStartTime).count(); + + OutputDebugStringW(L"Recording stopped. Committing GIF encoder...\n"); + OutputDebugStringW((L"Total frames captured: " + std::to_wstring(m_frameCount) + L"\n").c_str()); + OutputDebugStringW((L"Capture attempts: " + std::to_wstring(captureAttempts) + L"\n").c_str()); + OutputDebugStringW((L"Successful captures: " + std::to_wstring(successfulCaptures) + L"\n").c_str()); + OutputDebugStringW((L"Duplicated frames: " + std::to_wstring(duplicatedFrames) + L"\n").c_str()); + OutputDebugStringW((L"Recording duration: " + std::to_wstring(duration) + L"ms\n").c_str()); + OutputDebugStringW((L"Actual FPS: " + std::to_wstring(m_frameCount * 1000.0 / duration) + L"\n").c_str()); + + winrt::check_hresult(m_gifEncoder->Commit()); + OutputDebugStringW(L"GIF encoder committed successfully\n"); + } + } + catch (const winrt::hresult_error& error) + { + OutputDebugStringW(L"Error in GIF recording: "); + OutputDebugStringW(error.message().c_str()); + OutputDebugStringW(L"\n"); + + // Try to commit the encoder even on error + if (m_gifEncoder) + { + try + { + m_gifEncoder->Commit(); + } + catch (...) {} + } + + CloseInternal(); + } + } + co_return; +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::Close +// +//---------------------------------------------------------------------------- +void GifRecordingSession::Close() +{ + auto expected = false; + if (m_closed.compare_exchange_strong(expected, true)) + { + expected = true; + if (!m_isRecording.compare_exchange_strong(expected, false)) + { + CloseInternal(); + } + else + { + m_frameWait->StopCapture(); + } + } +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::CloseInternal +// +//---------------------------------------------------------------------------- +void GifRecordingSession::CloseInternal() +{ + m_frameWait->StopCapture(); + m_itemClosed.revoke(); +} diff --git a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h new file mode 100644 index 0000000000..90732f60f3 --- /dev/null +++ b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h @@ -0,0 +1,69 @@ +//============================================================================== +// +// Zoomit +// Sysinternals - www.sysinternals.com +// +// GIF recording support using Windows Imaging Component (WIC) +// +//============================================================================== +#pragma once + +#include "CaptureFrameWait.h" +#include +#include + +class GifRecordingSession : public std::enable_shared_from_this +{ +public: + [[nodiscard]] static std::shared_ptr Create( + winrt::Direct3D11::IDirect3DDevice const& device, + winrt::GraphicsCaptureItem const& item, + RECT const& cropRect, + uint32_t frameRate, + winrt::Streams::IRandomAccessStream const& stream); + ~GifRecordingSession(); + + winrt::IAsyncAction StartAsync(); + void EnableCursorCapture(bool enable = true) { m_frameWait->EnableCursorCapture(enable); } + void Close(); + +private: + GifRecordingSession( + winrt::Direct3D11::IDirect3DDevice const& device, + winrt::Capture::GraphicsCaptureItem const& item, + RECT const cropRect, + uint32_t frameRate, + winrt::Streams::IRandomAccessStream const& stream); + void CloseInternal(); + HRESULT EncodeFrame(ID3D11Texture2D* texture); + +private: + winrt::Direct3D11::IDirect3DDevice m_device{ nullptr }; + winrt::com_ptr m_d3dDevice; + winrt::com_ptr m_d3dContext; + RECT m_rcCrop; + uint32_t m_frameRate; + + winrt::GraphicsCaptureItem m_item{ nullptr }; + winrt::GraphicsCaptureItem::Closed_revoker m_itemClosed; + std::shared_ptr m_frameWait; + + winrt::Streams::IRandomAccessStream m_stream{ nullptr }; + + // WIC components for GIF encoding + winrt::com_ptr m_wicFactory; + winrt::com_ptr m_wicStream; + winrt::com_ptr m_gifEncoder; + winrt::com_ptr m_encoderMetadataWriter; + + std::atomic m_isRecording = false; + std::atomic m_closed = false; + + uint32_t m_frameWidth=0; + uint32_t m_frameHeight=0; + uint32_t m_frameDelay=0; + uint32_t m_frameCount = 0; + + int32_t m_width=0; + int32_t m_height=0; +}; diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.h b/src/modules/ZoomIt/ZoomIt/ZoomIt.h index 2687ba2b65..552af677ce 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.h +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.h @@ -39,6 +39,10 @@ type_pEnableThemeDialogTexture pEnableThemeDialogTexture; #define WIN7_VERSION 0x106 #define WIN10_VERSION 0x206 +// Default recording format frame rates +#define RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE 15 +#define RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE 30 + // Time that we'll cache live zoom window to avoid flicker // of live zooming on Vista/ws2k8 #define LIVEZOOM_WINDOW_TIMEOUT 2*3600*1000 diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc index 382c93208e..5f5e9d16cf 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc @@ -32,18 +32,18 @@ LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US // TEXTINCLUDE // -1 TEXTINCLUDE +1 TEXTINCLUDE BEGIN "resource.h\0" END -2 TEXTINCLUDE +2 TEXTINCLUDE BEGIN "#include ""winres.h""\r\n" "\0" END -3 TEXTINCLUDE +3 TEXTINCLUDE BEGIN "#include ""binres.rc""\0" END @@ -121,7 +121,7 @@ FONT 8, "MS Shell Dlg", 0, 0, 0x0 BEGIN DEFPUSHBUTTON "OK",IDOK,166,306,50,14 PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14 - LTEXT "ZoomIt v9.10",IDC_VERSION,42,7,73,10 + LTEXT "ZoomIt v9.21",IDC_VERSION,42,7,73,10 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 @@ -272,13 +272,15 @@ BEGIN LTEXT "Note: Recording is only available on Windows 10 (version 1903) and higher.",IDC_STATIC,7,77,246,19 LTEXT "Scaling:",IDC_STATIC,30,115,26,8 COMBOBOX IDC_RECORD_SCALING,61,114,26,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | WS_VSCROLL | WS_TABSTOP + LTEXT "Format:",IDC_STATIC,30,132,26,8 + COMBOBOX IDC_RECORD_FORMAT,61,131,60,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | WS_VSCROLL | WS_TABSTOP LTEXT "Frame Rate:",IDC_STATIC,119,115,44,8,NOT WS_VISIBLE COMBOBOX IDC_RECORD_FRAME_RATE,166,114,42,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | NOT WS_VISIBLE | WS_VSCROLL | WS_TABSTOP LTEXT "To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode. ",IDC_STATIC,7,32,246,19 LTEXT "To record a specific window, enter the hotkey with the Alt key in the opposite mode.",IDC_STATIC,7,55,246,19 - CONTROL "&Capture audio input:",IDC_CAPTURE_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,137,83,10 - COMBOBOX IDC_MICROPHONE,81,152,172,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP - LTEXT "Microphone:",IDC_STATIC,32,154,47,8 + CONTROL "&Capture audio input:",IDC_CAPTURE_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,149,83,10 + COMBOBOX IDC_MICROPHONE,81,164,172,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Microphone:",IDC_STATIC,32,166,47,8 END SNIP DIALOGEX 0, 0, 260, 68 diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj index 0b24f67061..1ecfbac9b7 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj @@ -81,6 +81,7 @@ MaxSpeed __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x602;NDEBUG;_WIN32_WINNT=0x602;_WIN32_WINDOWS=0x501;WIN32;_WINDOWS;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) true + MultiThreaded true @@ -102,6 +103,7 @@ MaxSpeed __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x602;NDEBUG;_WIN32_WINNT=0x602;_WIN32_WINDOWS=0x501;WIN32;_WINDOWS;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) true + MultiThreaded true @@ -124,6 +126,7 @@ MaxSpeed __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x602;NDEBUG;_WIN32_WINNT=0x602;_WIN32_WINDOWS=0x501;WIN32;_WINDOWS;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) true + MultiThreaded true @@ -145,6 +148,7 @@ Disabled __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x0602;_DEBUG;_WIN32_WINNT=0x602.MSVC6;_WIN32_WINDOWS=0x600;WIN32;_WINDOWS;_WIN32_WINNT=0x602;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) EnableFastChecks + MultiThreadedDebug _DEBUG;_M_IX86;%(PreprocessorDefinitions) @@ -165,6 +169,7 @@ Disabled __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x0602;_DEBUG;_WIN32_WINNT=0x602.MSVC6;_WIN32_WINDOWS=0x600;WIN32;_WINDOWS;_WIN32_WINNT=0x602;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) EnableFastChecks + MultiThreadedDebug _DEBUG;_M_X64;%(PreprocessorDefinitions) @@ -186,6 +191,7 @@ Disabled __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x0602;_DEBUG;_WIN32_WINNT=0x602.MSVC6;_WIN32_WINDOWS=0x600;WIN32;_WINDOWS;_WIN32_WINNT=0x602;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) EnableFastChecks + MultiThreadedDebug _DEBUG;_M_ARM64;%(PreprocessorDefinitions) @@ -234,6 +240,7 @@ NotUsing NotUsing + Use @@ -288,6 +295,7 @@ + diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters index 1754412d2d..e0416fe585 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters @@ -54,6 +54,9 @@ Source Files + + Source Files + @@ -95,6 +98,9 @@ Header Files + + Header Files + diff --git a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h index 96e2f19e5b..efd731cdce 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h +++ b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h @@ -3,6 +3,13 @@ #include "Registry.h" #include "DemoType.h" +// Recording format enum +enum class RecordingFormat +{ + GIF = 0, + MP4 = 1 +}; + DWORD g_ToggleKey = (HOTKEYF_CONTROL << 8)| '1'; DWORD g_LiveZoomToggleKey = ((HOTKEYF_CONTROL) << 8)| '4'; DWORD g_DrawToggleKey = ((HOTKEYF_CONTROL) << 8)| '2'; @@ -37,9 +44,11 @@ LOGFONT g_LogFont; BOOLEAN g_DemoTypeUserDriven = false; TCHAR g_DemoTypeFile[MAX_PATH] = {0}; DWORD g_DemoTypeSpeedSlider = static_cast(((MIN_TYPING_SPEED - MAX_TYPING_SPEED) / 2) + MAX_TYPING_SPEED); -DWORD g_RecordFrameRate = 30; -// Divide by 100 to get actual scaling -DWORD g_RecordScaling = 100; +DWORD g_RecordFrameRate = 30; // We default to 30 here, but g_RecordFrameRate can be different depending on recording format and gets set accordingly +DWORD g_RecordScaling = 100; +DWORD g_RecordScalingGIF = 50; +DWORD g_RecordScalingMP4 = 100; +RecordingFormat g_RecordingFormat = RecordingFormat::MP4; BOOLEAN g_CaptureAudio = FALSE; TCHAR g_MicrophoneDeviceId[MAX_PATH] = {0}; @@ -78,8 +87,9 @@ REG_SETTING RegSettings[] = { { L"SnapToGrid", SETTING_TYPE_BOOLEAN, 0, &g_SnapToGrid, static_cast(g_SnapToGrid) }, { L"ZoominSliderLevel", SETTING_TYPE_DWORD, 0, &g_SliderZoomLevel, static_cast(g_SliderZoomLevel) }, { L"Font", SETTING_TYPE_BINARY, sizeof g_LogFont, &g_LogFont, static_cast(0) }, - { L"RecordFrameRate", SETTING_TYPE_DWORD, 0, &g_RecordFrameRate, static_cast(g_RecordFrameRate) }, - { L"RecordScaling", SETTING_TYPE_DWORD, 0, &g_RecordScaling, static_cast(g_RecordScaling) }, + { L"RecordingFormat", SETTING_TYPE_DWORD, 0, &g_RecordingFormat, static_cast(g_RecordingFormat) }, + { L"RecordScalingGIF", SETTING_TYPE_DWORD, 0, &g_RecordScalingGIF, static_cast(g_RecordScalingGIF) }, + { L"RecordScalingMP4", SETTING_TYPE_DWORD, 0, &g_RecordScalingMP4, static_cast(g_RecordScalingMP4) }, { L"CaptureAudio", SETTING_TYPE_BOOLEAN, 0, &g_CaptureAudio, static_cast(g_CaptureAudio) }, { L"MicrophoneDeviceId", SETTING_TYPE_STRING, sizeof(g_MicrophoneDeviceId), g_MicrophoneDeviceId, static_cast(0) }, { NULL, SETTING_TYPE_DWORD, 0, NULL, static_cast(0) } diff --git a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp index fadc760339..71f09fc578 100644 --- a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp +++ b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp @@ -15,6 +15,7 @@ #include "Utility.h" #include "WindowsVersions.h" #include "ZoomItSettings.h" +#include "GifRecordingSession.h" #ifdef __ZOOMIT_POWERTOYS__ #include @@ -68,6 +69,8 @@ COLORREF g_CustomColors[16]; #define SNIP_SAVE_HOTKEY 9 #define DEMOTYPE_HOTKEY 10 #define DEMOTYPE_RESET_HOTKEY 11 +#define RECORD_GIF_HOTKEY 12 +#define RECORD_GIF_WINDOW_HOTKEY 13 #define ZOOM_PAGE 0 #define LIVE_PAGE 1 @@ -89,6 +92,11 @@ OPTION_TABS g_OptionsTabs[] = { { _T("Snip"), NULL } }; +static const TCHAR* g_RecordingFormats[] = { + _T("GIF"), + _T("MP4") +}; + float g_ZoomLevels[] = { 1.25, 1.50, @@ -99,6 +107,8 @@ float g_ZoomLevels[] = { }; DWORD g_FramerateOptions[] = { + 15, + 24, 30, 60 }; @@ -120,7 +130,7 @@ const float STRONG_BLUR_RADIUS = 40; DWORD g_ToggleMod; DWORD g_LiveZoomToggleMod; DWORD g_DrawToggleMod; -DWORD g_BreakToggleMod; +DWORD g_BreakToggleMod; DWORD g_DemoTypeToggleMod; DWORD g_RecordToggleMod; DWORD g_SnipToggleMod; @@ -152,12 +162,16 @@ BOOLEAN g_running = TRUE; // Screen recording globals #define DEFAULT_RECORDING_FILE L"Recording.mp4" +#define DEFAULT_GIF_RECORDING_FILE L"Recording.gif" + BOOL g_RecordToggle = FALSE; BOOL g_RecordCropping = FALSE; SelectRectangle g_SelectRectangle; std::wstring g_RecordingSaveLocation; +std::wstring g_RecordingSaveLocationGIF; winrt::IDirect3DDevice g_RecordDevice{ nullptr }; std::shared_ptr g_RecordingSession = nullptr; +std::shared_ptr g_GifRecordingSession = nullptr; type_pGetMonitorInfo pGetMonitorInfo; type_MonitorFromPoint pMonitorFromPoint; @@ -192,7 +206,7 @@ ComputerGraphicsInit g_GraphicsInit; //---------------------------------------------------------------------------- // -// Saves specified filePath to clipboard. +// Saves specified filePath to clipboard. // //---------------------------------------------------------------------------- bool SaveToClipboard( const WCHAR* filePath, HWND hwnd ) @@ -207,18 +221,18 @@ bool SaveToClipboard( const WCHAR* filePath, HWND hwnd ) HDROP hDrop = static_cast(GlobalAlloc( GHND, size )); if (hDrop == NULL) { - return false; + return false; } DROPFILES* dFiles = static_cast(GlobalLock( hDrop )); if (dFiles == NULL) { GlobalFree( hDrop ); - return false; + return false; } dFiles->pFiles = sizeof(DROPFILES); - dFiles->fWide = TRUE; + dFiles->fWide = TRUE; wcscpy( reinterpret_cast(& dFiles[1]), filePath); GlobalUnlock( hDrop ); @@ -345,7 +359,7 @@ VOID ErrorDialog( HWND hParent, PCTSTR message, DWORD _Error ) TCHAR errmsg[1024]; FormatMessage( FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, - NULL, _Error, + NULL, _Error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), reinterpret_cast(&lpMsgBuf), 0, NULL ); _stprintf( errmsg, L"%s: %s", message, lpMsgBuf ); @@ -391,7 +405,7 @@ VOID ErrorDialogString( HWND hParent, PCTSTR Message, const wchar_t *_Error ) // SetAutostartFilePath // // Sets the file path for later autostart config. -// +// //-------------------------------------------------------------------- void SetAutostartFilePath() { @@ -417,32 +431,32 @@ void SetAutostartFilePath() // ConfigureAutostart // // Enables or disables Zoomit autostart for the current image file. -// +// //-------------------------------------------------------------------- -bool ConfigureAutostart( HWND hParent, bool Enable ) +bool ConfigureAutostart( HWND hParent, bool Enable ) { HKEY hRunKey, hZoomit; DWORD error, length, type; TCHAR imageFile[MAX_PATH]; - error = RegOpenKeyEx( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", + error = RegOpenKeyEx( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, KEY_SET_VALUE, &hRunKey ); if( error == ERROR_SUCCESS ) { if( Enable ) { - - error = RegOpenKeyEx( HKEY_CURRENT_USER, _T("Software\\Sysinternals\\Zoomit"), 0, + + error = RegOpenKeyEx( HKEY_CURRENT_USER, _T("Software\\Sysinternals\\Zoomit"), 0, KEY_QUERY_VALUE, &hZoomit ); if( error == ERROR_SUCCESS ) { length = sizeof(imageFile); #ifdef _WIN64 // Unconditionally reset filepath in case this was already set by 32 bit version - SetAutostartFilePath(); + SetAutostartFilePath(); #endif error = RegQueryValueEx( hZoomit, _T( "Filepath" ), 0, &type, (BYTE *) imageFile, &length ); RegCloseKey( hZoomit ); - if( error == ERROR_SUCCESS ) { + if( error == ERROR_SUCCESS ) { error = RegSetValueEx( hRunKey, APPNAME, 0, REG_SZ, (BYTE *) imageFile, static_cast(_tcslen(imageFile)+1) * sizeof(TCHAR)); @@ -454,7 +468,7 @@ bool ConfigureAutostart( HWND hParent, bool Enable ) if( error == ERROR_FILE_NOT_FOUND ) error = ERROR_SUCCESS; } RegCloseKey( hRunKey ); - } + } if( error != ERROR_SUCCESS ) { ErrorDialog( hParent, L"Error configuring auto start", error ); @@ -468,15 +482,15 @@ bool ConfigureAutostart( HWND hParent, bool Enable ) // IsAutostartConfigured // // Is this version of zoomit configured to autostart. -// +// //-------------------------------------------------------------------- bool IsAutostartConfigured() { HKEY hRunKey; - TCHAR imageFile[MAX_PATH]; + TCHAR imageFile[MAX_PATH]; DWORD error, imageFileLength, type; - error = RegOpenKeyEx( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", + error = RegOpenKeyEx( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, KEY_QUERY_VALUE, &hRunKey ); if( error == ERROR_SUCCESS ) { @@ -496,15 +510,15 @@ bool IsAutostartConfigured() // // Returns true if this is the 32-bit version of the executable // and we're on 64-bit Windows. -// +// //-------------------------------------------------------------------- typedef BOOL (__stdcall *P_IS_WOW64PROCESS)( HANDLE hProcess, PBOOL Wow64Process ); -BOOL +BOOL RunningOnWin64( - VOID + VOID ) { P_IS_WOW64PROCESS pIsWow64Process; @@ -513,9 +527,9 @@ RunningOnWin64( pIsWow64Process = (P_IS_WOW64PROCESS) GetProcAddress(GetModuleHandle(_T("kernel32.dll")), "IsWow64Process"); if( pIsWow64Process ) { - + pIsWow64Process( GetCurrentProcess(), &isWow64 ); - } + } return isWow64; } @@ -524,7 +538,7 @@ RunningOnWin64( // // ExtractImageResource // -// Extracts the specified file that is located in a resource for +// Extracts the specified file that is located in a resource for // this executable. // //-------------------------------------------------------------------- @@ -532,15 +546,15 @@ BOOLEAN ExtractImageResource( PTCHAR ResourceName, PTCHAR TargetFile ) { HRSRC hResource; HGLOBAL hImageResource; - DWORD dwImageSize; + DWORD dwImageSize; LPVOID lpvImage; FILE *hFile; // Locate the resource - hResource = FindResource( NULL, ResourceName, _T("BINRES") ); - if( !hResource ) + hResource = FindResource( NULL, ResourceName, _T("BINRES") ); + if( !hResource ) return FALSE; - + hImageResource = LoadResource( NULL, hResource ); dwImageSize = SizeofResource( NULL, hResource ); lpvImage = LockResource( hImageResource ); @@ -562,10 +576,10 @@ BOOLEAN ExtractImageResource( PTCHAR ResourceName, PTCHAR TargetFile ) // // Returns true if this is the 32-bit version of the executable // and we're on 64-bit Windows. -// +// //-------------------------------------------------------------------- -DWORD -Run64bitVersion( +DWORD +Run64bitVersion( void ) { @@ -636,13 +650,13 @@ BOOLEAN IsPresentationMode() //---------------------------------------------------------------------------- // // EnableDisableSecondaryDisplay -// +// // Creates a second display on the secondary monitor for displaying the -// break timer. +// break timer. // //---------------------------------------------------------------------------- -LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, - PDEVMODE OriginalDevMode ) +LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, + PDEVMODE OriginalDevMode ) { LONG result; DEVMODE devMode{}; @@ -654,7 +668,7 @@ LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, // devMode.dmSize = sizeof(devMode); devMode.dmDriverExtra = 0; - EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, &devMode); + EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, &devMode); *OriginalDevMode = devMode; // @@ -667,7 +681,7 @@ LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFLAGS | - DM_DISPLAYFREQUENCY; + DM_DISPLAYFREQUENCY; result = ChangeDisplaySettingsEx( L"\\\\.\\DISPLAY2", &devMode, NULL, @@ -723,11 +737,11 @@ LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, // GetLineBounds // // Gets the rectangle bounding a line, taking into account pen width -// +// //---------------------------------------------------------------------------- Gdiplus::Rect GetLineBounds( POINT p1, POINT p2, int penWidth ) { - Gdiplus::Rect rect( min(p1.x, p2.x), min(p1.y, p2.y), + Gdiplus::Rect rect( min(p1.x, p2.x), min(p1.y, p2.y), abs(p1.x - p2.x), abs( p1.y - p2.y)); rect.Inflate( penWidth, penWidth ); return rect; @@ -738,7 +752,7 @@ Gdiplus::Rect GetLineBounds( POINT p1, POINT p2, int penWidth ) // InvalidateGdiplusRect // // Invalidate portion of window specified by Gdiplus::Rect -// +// //---------------------------------------------------------------------------- void InvalidateGdiplusRect(HWND hWnd, Gdiplus::Rect BoundsRect) { @@ -756,8 +770,8 @@ void InvalidateGdiplusRect(HWND hWnd, Gdiplus::Rect BoundsRect) // // CreateGdiplusBitmap // -// Creates a gdiplus bitmap of the specified region of the HDC. -// +// Creates a gdiplus bitmap of the specified region of the HDC. +// //---------------------------------------------------------------------------- Gdiplus::Bitmap *CreateGdiplusBitmap( HDC hDc, int x, int y, int Width, int Height ) { @@ -772,7 +786,7 @@ Gdiplus::Bitmap *CreateGdiplusBitmap( HDC hDc, int x, int y, int Width, int Heig Gdiplus::Bitmap *blurBitmap = new Gdiplus::Bitmap(hBitmap, NULL); DeleteDC(hdcNewBitmap); DeleteObject(hBitmap); - return blurBitmap; + return blurBitmap; } @@ -781,7 +795,7 @@ Gdiplus::Bitmap *CreateGdiplusBitmap( HDC hDc, int x, int y, int Width, int Heig // CreateBitmapMemoryDIB // // Creates a memory DC and DIB for the specified region of the screen. -// +// //---------------------------------------------------------------------------- BYTE* CreateBitmapMemoryDIB(HDC hdcScreenCompat, HDC hBitmapDc, Gdiplus::Rect* lineBounds, HDC* hdcMem, HBITMAP* hDIBOrig, HBITMAP* hPreviousBitmap) @@ -819,8 +833,8 @@ BYTE* CreateBitmapMemoryDIB(HDC hdcScreenCompat, HDC hBitmapDc, Gdiplus::Rect* l // // LockGdiPlusBitmap // -// Locks the Gdi+ bitmap so that we can access its pixels in memory. -// +// Locks the Gdi+ bitmap so that we can access its pixels in memory. +// //---------------------------------------------------------------------------- #ifdef _MSC_VER // Analyzers want us to use a scoped object instead of new. But given all the operations done in Bitmaps it seems better to leave it as a heap object. @@ -835,7 +849,7 @@ Gdiplus::BitmapData* LockGdiPlusBitmap(Gdiplus::Bitmap* Bitmap) Gdiplus::Rect lineBitmapBounds(0, 0, Bitmap->GetWidth(), Bitmap->GetHeight()); Bitmap->LockBits(&lineBitmapBounds, Gdiplus::ImageLockModeRead, Bitmap->GetPixelFormat(), lineData); - return lineData; + return lineData; } #ifdef _MSC_VER #pragma warning(pop) @@ -846,11 +860,11 @@ Gdiplus::BitmapData* LockGdiPlusBitmap(Gdiplus::Bitmap* Bitmap) // // BlurScreen // -// Blur the portion of the screen by copying a blurred bitmap with the -// specified shape. -// +// Blur the portion of the screen by copying a blurred bitmap with the +// specified shape. +// //---------------------------------------------------------------------------- -void BlurScreen(HDC hdcScreenCompat, Gdiplus::Rect* lineBounds, +void BlurScreen(HDC hdcScreenCompat, Gdiplus::Rect* lineBounds, Gdiplus::Bitmap *BlurBitmap, BYTE* pPixels) { HDC hdcDIB; @@ -896,8 +910,8 @@ void BlurScreen(HDC hdcScreenCompat, Gdiplus::Rect* lineBounds, // // BitmapBlur // -// Blurs the bitmap. -// +// Blurs the bitmap. +// //---------------------------------------------------------------------------- void BitmapBlur(Gdiplus::Bitmap* hBitmap) { @@ -928,7 +942,7 @@ void BitmapBlur(Gdiplus::Bitmap* hBitmap) // DrawBlurredShape // // Blur a shaped region of the screen. -// +// //---------------------------------------------------------------------------- void DrawBlurredShape( DWORD Shape, Gdiplus::Pen *pen, HDC hdcScreenCompat, Gdiplus::Graphics *dstGraphics, int x1, int y1, int x2, int y2) @@ -937,7 +951,7 @@ void DrawBlurredShape( DWORD Shape, Gdiplus::Pen *pen, HDC hdcScreenCompat, Gdip Gdiplus::Rect lineBounds( min( x1, x2 ), min( y1, y2 ), abs( x2 - x1 ), abs( y2 - y1 ) ); // Expand for line drawing - if (Shape == DRAW_LINE) + if (Shape == DRAW_LINE) lineBounds.Inflate( static_cast(g_PenWidth / 2), static_cast(g_PenWidth / 2) ); Gdiplus::Bitmap* lineBitmap = new Gdiplus::Bitmap(lineBounds.Width, lineBounds.Height, PixelFormat32bppARGB); @@ -978,7 +992,7 @@ void DrawBlurredShape( DWORD Shape, Gdiplus::Pen *pen, HDC hdcScreenCompat, Gdip // CreateDrawingBitmap // // Create a bitmap to draw on. -// +// //---------------------------------------------------------------------------- Gdiplus::Bitmap* CreateDrawingBitmap(Gdiplus::Rect lineBounds ) { @@ -992,8 +1006,8 @@ Gdiplus::Bitmap* CreateDrawingBitmap(Gdiplus::Rect lineBounds ) // // DrawBitmapLine // -// Creates a bitmap and draws a line on it. -// +// Creates a bitmap and draws a line on it. +// //---------------------------------------------------------------------------- Gdiplus::Bitmap* DrawBitmapLine(Gdiplus::Rect lineBounds, POINT p1, POINT p2, Gdiplus::Pen *pen) { @@ -1013,7 +1027,7 @@ Gdiplus::Bitmap* DrawBitmapLine(Gdiplus::Rect lineBounds, POINT p1, POINT p2, Gd // ColorFromColorRef // // Returns a color object from the colorRef that includes the alpha channel -// +// //---------------------------------------------------------------------------- Gdiplus::Color ColorFromColorRef(DWORD colorRef) { BYTE a = (colorRef >> 24) & 0xFF; // Extract the alpha channel value @@ -1028,8 +1042,8 @@ Gdiplus::Color ColorFromColorRef(DWORD colorRef) { // // AdjustHighlighterColor // -// Lighten the color. -// +// Lighten the color. +// //---------------------------------------------------------------------------- void AdjustHighlighterColor(BYTE* red, BYTE* green, BYTE* blue) { @@ -1044,8 +1058,8 @@ void AdjustHighlighterColor(BYTE* red, BYTE* green, BYTE* blue) { // BlendColors // // Blends two colors together using the alpha channel of the second color. -// The highlighter is the second color. -// +// The highlighter is the second color. +// //---------------------------------------------------------------------------- COLORREF BlendColors(COLORREF color1, const Gdiplus::Color& color2) { @@ -1068,7 +1082,7 @@ COLORREF BlendColors(COLORREF color1, const Gdiplus::Color& color2) { // int maxValue = max(red1, max(green1, blue1)); if(TRUE) { // red1 > 0x10 && red1 < 0xC0 && (maxValue - minValue < 0x40)) { - // This does a standard bright highlight + // This does a standard bright highlight alpha2 = 0; AdjustHighlighterColor( &red2, &green2, &blue2 ); redResult = red2 & red1; @@ -1095,7 +1109,7 @@ COLORREF BlendColors(COLORREF color1, const Gdiplus::Color& color2) { // Draws the shape with the highlighter color. // //---------------------------------------------------------------------------- -void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBrush, +void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBrush, Gdiplus::Pen *pPen, int x1, int y1, int x2, int y2) { // Create a new bitmap that's the size of the area covered by the line + 2 * g_PenWidth @@ -1115,7 +1129,7 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr break; case DRAW_ELLIPSE: lineGraphics.FillEllipse( pBrush, 0, 0, lineBounds.Width, lineBounds.Height); - break; + break; case DRAW_LINE: lineGraphics.DrawLine(pPen, x1 - lineBounds.X, y1 - lineBounds.Y, x2 - lineBounds.X, y2 - lineBounds.Y); break; @@ -1197,7 +1211,7 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr // // CreateFadedDesktopBackground // -// Creates a snapshot of the desktop that's faded and alpha blended with +// Creates a snapshot of the desktop that's faded and alpha blended with // black. // //---------------------------------------------------------------------------- @@ -1211,7 +1225,7 @@ HBITMAP CreateFadedDesktopBackground( HDC hdc, LPRECT rcScreen, LPRECT rcCrop ) HBITMAP hBitmap = CreateCompatibleBitmap( hdcScreen, width, height ); HBITMAP hOld = static_cast(SelectObject( hdcMem, hBitmap )); HBRUSH hBrush = CreateSolidBrush(RGB(0, 0, 0)); - + // start with black background FillRect( hdcMem, rcScreen, hBrush ); if(rcCrop != NULL && rcCrop->left != -1 ) { @@ -1227,8 +1241,8 @@ HBITMAP CreateFadedDesktopBackground( HDC hdc, LPRECT rcScreen, LPRECT rcCrop ) blend.BlendFlags = 0; blend.SourceConstantAlpha = 0x4F; blend.AlphaFormat = 0; - AlphaBlend( hdcMem,0, 0, width, height, - hdcScreen, rcScreen->left, rcScreen->top, + AlphaBlend( hdcMem,0, 0, width, height, + hdcScreen, rcScreen->left, rcScreen->top, width, height, blend ); SelectObject( hdcMem, hOld ); @@ -1249,9 +1263,9 @@ HBITMAP CreateFadedDesktopBackground( HDC hdc, LPRECT rcScreen, LPRECT rcCrop ) void AdjustToMoveBoundary( float zoomLevel, int *coordinate, int cursor, int size, int max ) { int diff = static_cast (static_cast(size)/ static_cast(LIVEZOOM_MOVE_REGIONS)); - if( cursor - *coordinate < diff ) - *coordinate = max( 0, cursor - diff ); - else if( (*coordinate + size) - cursor < diff ) + if( cursor - *coordinate < diff ) + *coordinate = max( 0, cursor - diff ); + else if( (*coordinate + size) - cursor < diff ) *coordinate = min( cursor + diff - size, max - size ); } @@ -1278,10 +1292,10 @@ void GetZoomedTopLeftCoordinates( float zoomLevel, POINT *cursorPos, int *x, int // // ScaleImage // -// Use gdi+ for anti-aliased bitmap stretching. +// Use gdi+ for anti-aliased bitmap stretching. // //---------------------------------------------------------------------------- -void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, +void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, HBITMAP bmSrc, float xSrc, float ySrc, float wSrc, float hSrc ) { Gdiplus::Graphics dstGraphics( hdcDst ); @@ -1331,7 +1345,7 @@ using namespace Gdiplus; *pClsid = pImageCodecInfo[j].Clsid; free(pImageCodecInfo); return j; // Success - } + } } free(pImageCodecInfo); @@ -1339,20 +1353,20 @@ using namespace Gdiplus; } //---------------------------------------------------------------------- -// +// // ConvertToUnicode // //---------------------------------------------------------------------- -void -ConvertToUnicode( - PCHAR aString, - PWCHAR wString, - DWORD wStringLength +void +ConvertToUnicode( + PCHAR aString, + PWCHAR wString, + DWORD wStringLength ) { size_t len; - len = MultiByteToWideChar( CP_ACP, 0, aString, static_cast(strlen(aString)), + len = MultiByteToWideChar( CP_ACP, 0, aString, static_cast(strlen(aString)), wString, wStringLength ); wString[len] = 0; } @@ -1411,14 +1425,14 @@ void EnableDisableTrayIcon( HWND hWnd, BOOLEAN Enable ) NOTIFYICONDATA tNotifyIconData; memset( &tNotifyIconData, 0, sizeof(tNotifyIconData)); - tNotifyIconData.cbSize = sizeof(NOTIFYICONDATA); - tNotifyIconData.hWnd = hWnd; - tNotifyIconData.uID = 1; - tNotifyIconData.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; - tNotifyIconData.uCallbackMessage = WM_USER_TRAY_ACTIVATE; - tNotifyIconData.hIcon = LoadIcon( g_hInstance, L"APPICON" ); + tNotifyIconData.cbSize = sizeof(NOTIFYICONDATA); + tNotifyIconData.hWnd = hWnd; + tNotifyIconData.uID = 1; + tNotifyIconData.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + tNotifyIconData.uCallbackMessage = WM_USER_TRAY_ACTIVATE; + tNotifyIconData.hIcon = LoadIcon( g_hInstance, L"APPICON" ); lstrcpyn(tNotifyIconData.szTip, APPNAME, sizeof(APPNAME)); - Shell_NotifyIcon(Enable ? NIM_ADD : NIM_DELETE, &tNotifyIconData); + Shell_NotifyIcon(Enable ? NIM_ADD : NIM_DELETE, &tNotifyIconData); } //---------------------------------------------------------------------------- @@ -1426,7 +1440,7 @@ void EnableDisableTrayIcon( HWND hWnd, BOOLEAN Enable ) // EnableDisableOpacity // //---------------------------------------------------------------------------- -void EnableDisableOpacity( HWND hWnd, BOOLEAN Enable ) +void EnableDisableOpacity( HWND hWnd, BOOLEAN Enable ) { DWORD exStyle; @@ -1453,11 +1467,11 @@ void EnableDisableOpacity( HWND hWnd, BOOLEAN Enable ) // EnableDisableScreenSaver // //---------------------------------------------------------------------------- -void EnableDisableScreenSaver( BOOLEAN Enable ) +void EnableDisableScreenSaver( BOOLEAN Enable ) { - SystemParametersInfo(SPI_SETSCREENSAVEACTIVE,Enable,0,0); - SystemParametersInfo(SPI_SETPOWEROFFACTIVE,Enable,0,0); - SystemParametersInfo(SPI_SETLOWPOWERACTIVE,Enable,0,0); + SystemParametersInfo(SPI_SETSCREENSAVEACTIVE,Enable,0,0); + SystemParametersInfo(SPI_SETPOWEROFFACTIVE,Enable,0,0); + SystemParametersInfo(SPI_SETLOWPOWERACTIVE,Enable,0,0); } //---------------------------------------------------------------------------- @@ -1470,25 +1484,25 @@ void EnableDisableStickyKeys( BOOLEAN Enable ) static STICKYKEYS prevStickyKeyValue = {0}; STICKYKEYS newStickyKeyValue = {0}; - // Need to do this on Vista tablet to stop sticky key popup when you + // Need to do this on Vista tablet to stop sticky key popup when you // hold down the shift key and draw with the pen. if( Enable ) { if( prevStickyKeyValue.cbSize == sizeof(STICKYKEYS)) { - SystemParametersInfo(SPI_SETSTICKYKEYS, + SystemParametersInfo(SPI_SETSTICKYKEYS, sizeof(STICKYKEYS), &prevStickyKeyValue, SPIF_SENDCHANGE); } } else { prevStickyKeyValue.cbSize = sizeof(STICKYKEYS); - if (SystemParametersInfo(SPI_GETSTICKYKEYS, sizeof(STICKYKEYS), + if (SystemParametersInfo(SPI_GETSTICKYKEYS, sizeof(STICKYKEYS), &prevStickyKeyValue, 0)) { newStickyKeyValue.cbSize = sizeof(STICKYKEYS); newStickyKeyValue.dwFlags = 0; - if( !SystemParametersInfo(SPI_SETSTICKYKEYS, + if( !SystemParametersInfo(SPI_SETSTICKYKEYS, sizeof(STICKYKEYS), &newStickyKeyValue, SPIF_SENDCHANGE)) { // DWORD error = GetLastError(); @@ -1520,7 +1534,7 @@ constexpr DWORD GetKeyMod( DWORD Key ) // AdvancedBreakProc // //---------------------------------------------------------------------------- -INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) +INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) { TCHAR opacity[10]; static TCHAR newSoundFile[MAX_PATH]; @@ -1535,9 +1549,9 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR pSHAutoComplete( GetDlgItem( hDlg, IDC_SOUND_FILE), SHACF_FILESYSTEM ); pSHAutoComplete( GetDlgItem( hDlg, IDC_BACKGROUND_FILE), SHACF_FILESYSTEM ); } - CheckDlgButton( hDlg, IDC_CHECK_BACKGROUND_FILE, + CheckDlgButton( hDlg, IDC_CHECK_BACKGROUND_FILE, g_BreakShowBackgroundFile ? BST_CHECKED: BST_UNCHECKED ); - CheckDlgButton( hDlg, IDC_CHECK_SOUND_FILE, + CheckDlgButton( hDlg, IDC_CHECK_SOUND_FILE, g_BreakPlaySoundFile ? BST_CHECKED: BST_UNCHECKED ); CheckDlgButton( hDlg, IDC_CHECK_SHOW_EXPIRED, g_ShowExpiredTime ? BST_CHECKED : BST_UNCHECKED ); @@ -1575,7 +1589,7 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_BROWSE ), FALSE ); EnableWindow( GetDlgItem( hDlg, IDC_CHECK_BACKGROUND_STRETCH ), FALSE ); } - CheckDlgButton( hDlg, + CheckDlgButton( hDlg, g_BreakShowDesktop ? IDC_STATIC_DESKTOP_BACKGROUND : IDC_STATIC_BACKGROUND_FILE, BST_CHECKED ); _tcscpy( newBackgroundFile, g_BreakBackgroundFile ); SetDlgItemText( hDlg, IDC_BACKGROUND_FILE, g_BreakBackgroundFile ); @@ -1585,10 +1599,10 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR for( i = 10; i <= 100; i += 10) { _stprintf( opacity, L"%d%%", i ); - SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_ADDSTRING, 0, + SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_ADDSTRING, 0, reinterpret_cast(opacity)); } - SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_SETCURSEL, + SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_SETCURSEL, g_BreakOpacity / 10 - 1, 0 ); return TRUE; @@ -1597,25 +1611,25 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR case BN_CLICKED: if( LOWORD( wParam ) == IDC_CHECK_SOUND_FILE ) { - EnableWindow( GetDlgItem( hDlg, IDC_STATIC_SOUND_FILE ), + EnableWindow( GetDlgItem( hDlg, IDC_STATIC_SOUND_FILE ), IsDlgButtonChecked( hDlg, IDC_CHECK_SOUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_SOUND_FILE ), + EnableWindow( GetDlgItem( hDlg, IDC_SOUND_FILE ), + IsDlgButtonChecked( hDlg, IDC_CHECK_SOUND_FILE) == BST_CHECKED ); + EnableWindow( GetDlgItem( hDlg, IDC_SOUND_BROWSE ), IsDlgButtonChecked( hDlg, IDC_CHECK_SOUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_SOUND_BROWSE ), - IsDlgButtonChecked( hDlg, IDC_CHECK_SOUND_FILE) == BST_CHECKED ); } if( LOWORD( wParam ) == IDC_CHECK_BACKGROUND_FILE ) { - EnableWindow( GetDlgItem( hDlg, IDC_CHECK_BACKGROUND_STRETCH ), + EnableWindow( GetDlgItem( hDlg, IDC_CHECK_BACKGROUND_STRETCH ), IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_STATIC_DESKTOP_BACKGROUND ), + EnableWindow( GetDlgItem( hDlg, IDC_STATIC_DESKTOP_BACKGROUND ), IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_STATIC_BACKGROUND_FILE ), + EnableWindow( GetDlgItem( hDlg, IDC_STATIC_BACKGROUND_FILE ), IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_FILE ), + EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_FILE ), + IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); + EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_BROWSE ), IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_BROWSE ), - IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); } break; } @@ -1704,7 +1718,7 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR #endif if( g_BreakPlaySoundFile && GetFileAttributes( newSoundFile ) == -1 ) { - MessageBox( hDlg, L"The specified sound file is inaccessible", + MessageBox( hDlg, L"The specified sound file is inaccessible", L"Advanced Break Options Error", MB_ICONERROR ); break; } @@ -1715,7 +1729,7 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR if( !g_BreakShowDesktop && g_BreakShowBackgroundFile && GetFileAttributes( newBackgroundFile ) == -1 ) { - MessageBox( hDlg, L"The specified background file is inaccessible", + MessageBox( hDlg, L"The specified background file is inaccessible", L"Advanced Break Options Error", MB_ICONERROR ); break; } @@ -1729,7 +1743,7 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR break; } } - GetDlgItemText( hDlg, IDC_OPACITY, opacity, sizeof(opacity)/sizeof(opacity[0])); + GetDlgItemText( hDlg, IDC_OPACITY, opacity, sizeof(opacity)/sizeof(opacity[0])); _stscanf( opacity, L"%d%%", &g_BreakOpacity ); reg.WriteRegSettings( RegSettings ); EndDialog(hDlg, 0); @@ -1753,14 +1767,14 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR // OptionsTabProc // //---------------------------------------------------------------------------- -INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, - WPARAM wParam, LPARAM lParam ) +INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, + WPARAM wParam, LPARAM lParam ) { HDC hDC; LOGFONT lf; CHOOSEFONT chooseFont; HFONT hFont; - PAINTSTRUCT ps; + PAINTSTRUCT ps; HWND hTextPreview; HDC hDc; RECT previewRc; @@ -1771,6 +1785,58 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, case WM_INITDIALOG: return TRUE; case WM_COMMAND: + // Handle combo box selection changes + if (HIWORD(wParam) == CBN_SELCHANGE) { + if (LOWORD(wParam) == IDC_RECORD_SCALING) { + + int format = static_cast(SendMessage(GetDlgItem(hDlg, IDC_RECORD_FORMAT), CB_GETCURSEL, 0, 0)); + int scale = static_cast(SendMessage(GetDlgItem(hDlg, IDC_RECORD_SCALING), CB_GETCURSEL, 0, 0)); + if(format == 0) + { + g_RecordScalingGIF = static_cast((scale + 1) * 10); + } + else + { + g_RecordScalingMP4 = static_cast((scale + 1) * 10); + } + } + else if (LOWORD(wParam) == IDC_RECORD_FORMAT) { + // Get the currently selected format + int selection = static_cast(SendMessage(GetDlgItem(hDlg, IDC_RECORD_FORMAT), + CB_GETCURSEL, 0, 0)); + + // Get the selected text to check if it's GIF + TCHAR selectedText[32] = {0}; + SendMessage(GetDlgItem(hDlg, IDC_RECORD_FORMAT), + CB_GETLBTEXT, selection, reinterpret_cast(selectedText)); + + // Check if GIF is selected by comparing the text + bool isGifSelected = (wcscmp(selectedText, L"GIF") == 0); + + // If GIF is selected, set the scaling to the g_RecordScalingGIF value; otherwise to the g_RecordScalingMP4 value + if (isGifSelected) { + g_RecordScaling = g_RecordScalingGIF; + + } else { + + g_RecordScaling = g_RecordScalingMP4; + } + + for (int i = 0; i < 10; i++) { + int scalingValue = (i + 1) * 10; + if (scalingValue == static_cast(g_RecordScaling)) { + SendMessage(GetDlgItem(hDlg, IDC_RECORD_SCALING), + CB_SETCURSEL, i, 0); + break; + } + } + + // Enable/disable microphone controls based on selection + EnableWindow(GetDlgItem(hDlg, IDC_MICROPHONE), !isGifSelected); + EnableWindow(GetDlgItem(hDlg, IDC_CAPTURE_AUDIO), !isGifSelected); + } + } + switch ( LOWORD( wParam )) { case IDC_ADVANCED_BREAK: DialogBox( g_hInstance, L"ADVANCED_BREAK", hDlg, AdvancedBreakProc ); @@ -1785,7 +1851,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, chooseFont.hwndOwner = hDlg; chooseFont.lpLogFont = &lf; chooseFont.Flags = CF_SCREENFONTS|CF_ENABLETEMPLATE| - CF_INITTOLOGFONTSTRUCT|CF_LIMITSIZE; + CF_INITTOLOGFONTSTRUCT|CF_LIMITSIZE; chooseFont.rgbColors = RGB (0, 0, 0); chooseFont.lCustData = 0; chooseFont.nSizeMin = 16; @@ -1811,7 +1877,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, openFileName.nFilterIndex = 1; openFileName.lpstrFilter = L"All Files\0*.*\0\0"; openFileName.lpstrFile = filePath; - + if( GetOpenFileName( &openFileName ) ) { if( GetFileAttributes( filePath ) == -1 ) @@ -1835,20 +1901,20 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, LOGFONT _lf = g_LogFont; _lf.lfHeight = -21; hFont = CreateFontIndirect( &_lf); - hDc = BeginPaint(hDlg, &ps); + hDc = BeginPaint(hDlg, &ps); SelectObject( hDc, hFont ); GetWindowRect( hTextPreview, &previewRc ); - MapWindowPoints( NULL, hDlg, reinterpret_cast(&previewRc), 2); + MapWindowPoints( NULL, hDlg, reinterpret_cast(&previewRc), 2); previewRc.top += 6; - DrawText( hDc, L"Sample", static_cast(_tcslen(L"Sample")), &previewRc, + DrawText( hDc, L"Sample", static_cast(_tcslen(L"Sample")), &previewRc, DT_CENTER|DT_VCENTER|DT_SINGLELINE ); EndPaint( hDlg, &ps ); DeleteObject( hFont ); } - break; + break; default: break; } @@ -1861,7 +1927,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, // OptionsAddTabs // //---------------------------------------------------------------------------- -VOID OptionsAddTabs( HWND hOptionsDlg, HWND hTabCtrl ) +VOID OptionsAddTabs( HWND hOptionsDlg, HWND hTabCtrl ) { int i; TCITEM tcItem; @@ -1873,14 +1939,14 @@ VOID OptionsAddTabs( HWND hOptionsDlg, HWND hTabCtrl ) tcItem.mask = TCIF_TEXT; tcItem.pszText = g_OptionsTabs[i].TabTitle; TabCtrl_InsertItem( hTabCtrl, i, &tcItem ); - g_OptionsTabs[i].hPage = CreateDialog( g_hInstance, g_OptionsTabs[i].TabTitle, + g_OptionsTabs[i].hPage = CreateDialog( g_hInstance, g_OptionsTabs[i].TabTitle, hOptionsDlg, OptionsTabProc ); } TabCtrl_AdjustRect( hTabCtrl, FALSE, &rc ); for( i = 0; i < sizeof( g_OptionsTabs )/sizeof(g_OptionsTabs[0]); i++ ) { pageRc = rc; - MapWindowPoints( NULL, g_OptionsTabs[i].hPage, reinterpret_cast(&pageRc), 2); + MapWindowPoints( NULL, g_OptionsTabs[i].hPage, reinterpret_cast(&pageRc), 2); SetWindowPos( g_OptionsTabs[i].hPage, HWND_TOP, @@ -1914,6 +1980,8 @@ void UnregisterAllHotkeys( HWND hWnd ) UnregisterHotKey( hWnd, SNIP_SAVE_HOTKEY); UnregisterHotKey( hWnd, DEMOTYPE_HOTKEY ); UnregisterHotKey( hWnd, DEMOTYPE_RESET_HOTKEY ); + UnregisterHotKey( hWnd, RECORD_GIF_HOTKEY ); + UnregisterHotKey( hWnd, RECORD_GIF_WINDOW_HOTKEY ); } //---------------------------------------------------------------------------- @@ -1943,6 +2011,9 @@ void RegisterAllHotkeys(HWND hWnd) RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, (g_RecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF); RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, (g_RecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF); } + // Register CTRL+8 for GIF recording and CTRL+ALT+8 for GIF window recording + RegisterHotKey(hWnd, RECORD_GIF_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, 568 && 0xFF); + RegisterHotKey(hWnd, RECORD_GIF_WINDOW_HOTKEY, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, 568 && 0xFF); } @@ -1996,8 +2067,8 @@ void UpdateDrawTabHeaderFont() // OptionsProc // //---------------------------------------------------------------------------- -INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, - WPARAM wParam, LPARAM lParam ) +INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, + WPARAM wParam, LPARAM lParam ) { static HFONT hFontBold = nullptr; PNMLINK notify = nullptr; @@ -2026,7 +2097,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, SetForegroundWindow( hDlg ); SetActiveWindow( hDlg ); - SetWindowPos( hDlg, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW ); + SetWindowPos( hDlg, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW ); #if 1 // set version info TCHAR filePath[MAX_PATH]; @@ -2054,9 +2125,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, UpdateDrawTabHeaderFont(); // Configure options - SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_SETRULES, - static_cast(HKCOMB_NONE), // invalid key combinations - MAKELPARAM(HOTKEYF_ALT, 0)); // add ALT to invalid entries + SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_SETRULES, + static_cast(HKCOMB_NONE), // invalid key combinations + MAKELPARAM(HOTKEYF_ALT, 0)); // add ALT to invalid entries if( g_ToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_SETHOTKEY, g_ToggleKey, 0 ); if( pMagInitialize ) { @@ -2074,13 +2145,13 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, if( g_DemoTypeToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_SETHOTKEY, g_DemoTypeToggleKey, 0 ); if( g_RecordToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_SETHOTKEY, g_RecordToggleKey, 0 ); if( g_SnipToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_SETHOTKEY, g_SnipToggleKey, 0 ); - CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON, + CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON, g_ShowTrayIcon ? BST_CHECKED: BST_UNCHECKED ); - CheckDlgButton( hDlg, IDC_AUTOSTART, + CheckDlgButton( hDlg, IDC_AUTOSTART, IsAutostartConfigured() ? BST_CHECKED: BST_UNCHECKED ); - CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM, + CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM, g_AnimateZoom ? BST_CHECKED: BST_UNCHECKED ); - CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE, + 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) ); @@ -2089,21 +2160,24 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, _stprintf( text, L"%d", g_PenWidth ); SetDlgItemText( g_OptionsTabs[DRAW_PAGE].hPage, IDC_PEN_WIDTH, text ); SendMessage( GetDlgItem( g_OptionsTabs[DRAW_PAGE].hPage, IDC_PEN_WIDTH ), EM_LIMITTEXT, 1, 0 ); - SendMessage (GetDlgItem( g_OptionsTabs[DRAW_PAGE].hPage, IDC_SPIN), UDM_SETRANGE, 0L, + SendMessage (GetDlgItem( g_OptionsTabs[DRAW_PAGE].hPage, IDC_SPIN), UDM_SETRANGE, 0L, MAKELPARAM (19, 1)); _stprintf( text, L"%d", g_BreakTimeout ); SetDlgItemText( g_OptionsTabs[BREAK_PAGE].hPage, IDC_TIMER, text ); SendMessage( GetDlgItem( g_OptionsTabs[BREAK_PAGE].hPage, IDC_TIMER ), EM_LIMITTEXT, 2, 0 ); - SendMessage (GetDlgItem( g_OptionsTabs[BREAK_PAGE].hPage, IDC_SPIN_TIMER), UDM_SETRANGE, 0L, + SendMessage (GetDlgItem( g_OptionsTabs[BREAK_PAGE].hPage, IDC_SPIN_TIMER), UDM_SETRANGE, 0L, MAKELPARAM (99, 1)); CheckDlgButton( g_OptionsTabs[BREAK_PAGE].hPage, IDC_CHECK_SHOW_EXPIRED, g_ShowExpiredTime ? BST_CHECKED : BST_UNCHECKED ); - CheckDlgButton( g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO, + CheckDlgButton( g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO, g_CaptureAudio ? BST_CHECKED: BST_UNCHECKED ); - for (int i = 0; i < _countof(g_FramerateOptions); i++) { + // + // The framerate drop down list is not used in the current version (might be added in the future) + // + /*for (int i = 0; i < _countof(g_FramerateOptions); i++) { _stprintf(text, L"%d", g_FramerateOptions[i]); SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), static_cast(CB_ADDSTRING), @@ -2112,13 +2186,34 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), CB_SETCURSEL, static_cast(i), static_cast(0)); } + }*/ + + // Add the recording format to the combo box and set the current selection + size_t selection = 0; + const wchar_t* currentFormatString = (g_RecordingFormat == RecordingFormat::GIF) ? L"GIF" : L"MP4"; + + for( size_t i = 0; i < (sizeof(g_RecordingFormats) / sizeof(g_RecordingFormats[0])); i++ ) + { + SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FORMAT ), static_cast(CB_ADDSTRING), static_cast(0), reinterpret_cast(g_RecordingFormats[i]) ); + + if( selection == 0 && wcscmp( g_RecordingFormats[i], currentFormatString ) == 0 ) + { + selection = i; + } } + SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FORMAT ), CB_SETCURSEL, static_cast(selection), static_cast(0) ); + for(unsigned int i = 1; i < 11; i++) { _stprintf(text, L"%2.1f", (static_cast(i)) / 10 ); SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), static_cast(CB_ADDSTRING), static_cast(0), reinterpret_cast(text)); - if (g_RecordScaling == i*10 ) { + + if (g_RecordingFormat == RecordingFormat::GIF && i*10 == g_RecordScalingGIF ) { + + SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), CB_SETCURSEL, static_cast(i)-1, static_cast(0)); + } + if (g_RecordingFormat == RecordingFormat::MP4 && i*10 == g_RecordScalingMP4 ) { SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), CB_SETCURSEL, static_cast(i)-1, static_cast(0)); } @@ -2136,7 +2231,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, // Add the microphone devices to the combo box and set the current selection SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE ), static_cast(CB_ADDSTRING), static_cast(0), reinterpret_cast(L"Default")); - size_t selection = 0; + selection = 0; for( size_t i = 0; i < microphones.size(); i++ ) { SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE ), static_cast(CB_ADDSTRING), static_cast(0), reinterpret_cast(microphones[i].second.c_str()) ); @@ -2147,6 +2242,11 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, } SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE ), CB_SETCURSEL, static_cast(selection), static_cast(0) ); + // Set initial state of microphone controls based on recording format + bool isGifSelected = (g_RecordingFormat == RecordingFormat::GIF); + EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE), !isGifSelected); + EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO), !isGifSelected); + if( GetFileAttributes( g_DemoTypeFile ) == -1 ) { memset( g_DemoTypeFile, 0, sizeof( g_DemoTypeFile ) ); @@ -2176,7 +2276,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, break; case WM_CTLCOLORSTATIC: - if( reinterpret_cast(lParam) == GetDlgItem( hDlg, IDC_TITLE ) || + if( reinterpret_cast(lParam) == GetDlgItem( hDlg, IDC_TITLE ) || reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_DRAWING) || reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_ZOOM) || reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_BREAK) || @@ -2249,7 +2349,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, text[2] = 0; newTimeout = _tstoi( text ); - g_RecordFrameRate = g_FramerateOptions[SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), static_cast(CB_GETCURSEL), static_cast(0), static_cast(0))]; + g_RecordingFormat = static_cast(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FORMAT), static_cast(CB_GETCURSEL), static_cast(0), static_cast(0))); + g_RecordFrameRate = (g_RecordingFormat == RecordingFormat::GIF) ? RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE : RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE; g_RecordScaling = static_cast(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), static_cast(CB_GETCURSEL), static_cast(0), static_cast(0)) * 10 + 10); // Get the selected microphone @@ -2263,7 +2364,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, UnregisterAllHotkeys(GetParent( hDlg )); break; - } else if(newLiveZoomToggleKey && + } else if(newLiveZoomToggleKey && (!RegisterHotKey( GetParent( hDlg ), LIVE_HOTKEY, newLiveZoomToggleMod, newLiveZoomToggleKey & 0xFF ) || !RegisterHotKey(GetParent(hDlg), LIVE_DRAW_HOTKEY, (newLiveZoomToggleMod ^ MOD_SHIFT), newLiveZoomToggleKey & 0xFF))) { @@ -2286,7 +2387,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, UnregisterAllHotkeys(GetParent( hDlg )); break; - } else if( newDemoTypeToggleKey && + } else if( newDemoTypeToggleKey && (!RegisterHotKey( GetParent( hDlg ), DEMOTYPE_HOTKEY, newDemoTypeToggleMod, newDemoTypeToggleKey & 0xFF ) || !RegisterHotKey(GetParent(hDlg), DEMOTYPE_RESET_HOTKEY, (newDemoTypeToggleMod ^ MOD_SHIFT), newDemoTypeToggleKey & 0xFF))) { @@ -2296,7 +2397,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, break; } - else if (newSnipToggleKey && + else if (newSnipToggleKey && (!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF) || !RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, (newSnipToggleMod ^ MOD_SHIFT), newSnipToggleKey & 0xFF))) { @@ -2305,8 +2406,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, UnregisterAllHotkeys(GetParent(hDlg)); break; - } - else if( newRecordToggleKey && + } + else if( newRecordToggleKey && (!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) || !RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, (newRecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, newRecordToggleKey & 0xFF) || !RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, (newRecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, newRecordToggleKey & 0xFF))) { @@ -2317,7 +2418,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, break; } else { - + g_BreakTimeout = newTimeout; g_ToggleKey = newToggleKey; g_LiveZoomToggleKey = newLiveZoomToggleKey; @@ -2337,7 +2438,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, hWndOptions = NULL; EndDialog( hDlg, 0 ); - return TRUE; + return TRUE; } break; } @@ -2348,8 +2449,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, EndDialog( hDlg, 0 ); return TRUE; } - break; - + break; + case WM_CLOSE: hWndOptions = NULL; RegisterAllHotkeys(GetParent(hDlg)); @@ -2388,7 +2489,7 @@ void DeleteDrawUndoList( P_DRAW_UNDO *DrawUndoList ) // PopDrawUndo // //---------------------------------------------------------------------------- -BOOLEAN PopDrawUndo( HDC hDc, P_DRAW_UNDO *DrawUndoList, +BOOLEAN PopDrawUndo( HDC hDc, P_DRAW_UNDO *DrawUndoList, int width, int height ) { P_DRAW_UNDO nextUndo; @@ -2396,7 +2497,7 @@ BOOLEAN PopDrawUndo( HDC hDc, P_DRAW_UNDO *DrawUndoList, nextUndo = *DrawUndoList; if( nextUndo ) { - BitBlt( hDc, 0, 0, width, height, + BitBlt( hDc, 0, 0, width, height, nextUndo->hDc, 0, 0, SRCCOPY|CAPTUREBLT ); *DrawUndoList = nextUndo->Next; DeleteObject( nextUndo->hBitmap ); @@ -2444,7 +2545,7 @@ void DeleteOldestUndo( P_DRAW_UNDO *DrawUndoList ) //---------------------------------------------------------------------------- // // GetOldestUndo -// +// //---------------------------------------------------------------------------- P_DRAW_UNDO GetOldestUndo(P_DRAW_UNDO DrawUndoList) { @@ -2509,7 +2610,7 @@ void PushDrawUndo( HDC hDc, P_DRAW_UNDO *DrawUndoList, int width, int height ) newUndo->Next = *DrawUndoList; *DrawUndoList = newUndo; } - } + } } //---------------------------------------------------------------------------- @@ -2562,8 +2663,8 @@ void ClearTypingCursor( HDC hdcScreenCompat, HDC hdcScreenCursorCompat, RECT rc, } else { - BitBlt(hdcScreenCompat, rc.left, rc.top, rc.right - rc.left, - rc.bottom - rc.top, hdcScreenCursorCompat,0, 0, SRCCOPY|CAPTUREBLT ); + BitBlt(hdcScreenCompat, rc.left, rc.top, rc.right - rc.left, + rc.bottom - rc.top, hdcScreenCursorCompat,0, 0, SRCCOPY|CAPTUREBLT ); } } @@ -2618,7 +2719,7 @@ RECT BoundMouse( float zoomLevel, MONITORINFO *monInfo, int width, int height, int x, y; GetZoomedTopLeftCoordinates( zoomLevel, cursorPos, &x, width, &y, height ); - rc.left = monInfo->rcMonitor.left + x; + rc.left = monInfo->rcMonitor.left + x; rc.right = rc.left + static_cast(width/zoomLevel); rc.top = monInfo->rcMonitor.top + y; rc.bottom = rc.top + static_cast(height/zoomLevel); @@ -2629,7 +2730,7 @@ RECT BoundMouse( float zoomLevel, MONITORINFO *monInfo, int width, int height, rc.left, rc.top, rc.right, rc.bottom); OutputDebug( L"mon.left: %d mon.top: %d mon.right: %d mon.bottom: %d\n", monInfo->rcMonitor.left, monInfo->rcMonitor.top, monInfo->rcMonitor.right, monInfo->rcMonitor.bottom); - + ClipCursor( &rc ); return rc; } @@ -2657,7 +2758,7 @@ void DrawArrow( HDC hdc, int x1, int y1, int x2, int y2, double length, double w // get midpoint of base int xMid = x2 - static_cast(length*dx+0.5); int yMid = y2 - static_cast(length*dy+0.5); - + // get left wing int xLeft = xMid - static_cast(dy*width+0.5); int yLeft = yMid + static_cast(dx*width+0.5); @@ -2763,7 +2864,7 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false ) case DRAW_RECTANGLE: if (UseGdiPlus) if(pBrush) - DrawHighlightedShape(DRAW_RECTANGLE, hDc, pBrush, NULL, + DrawHighlightedShape(DRAW_RECTANGLE, hDc, pBrush, NULL, static_cast(Rect->left - 1), static_cast(Rect->top - 1), static_cast(Rect->right), static_cast(Rect->bottom)); else if (isBlur) @@ -2840,7 +2941,7 @@ VOID SendPenMessage(HWND hWnd, UINT Message, LPARAM lParam) if(GetKeyState(VK_LCONTROL) < 0 ) { wParam |= MK_CONTROL; - } + } if( GetKeyState( VK_LSHIFT) < 0 || GetKeyState( VK_RSHIFT) < 0 ) { wParam |= MK_SHIFT; @@ -2853,10 +2954,10 @@ VOID SendPenMessage(HWND hWnd, UINT Message, LPARAM lParam) //---------------------------------------------------------------------------- // // ScalePenPosition -// +// // Maps pen input to mouse input coordinates based on zoom level. Returns // 0 if pen is active but we didn't send this message to ourselves (pen -// signature will be missing). +// signature will be missing). // //---------------------------------------------------------------------------- LPARAM ScalePenPosition( float zoomLevel, MONITORINFO *monInfo, RECT boundRc, @@ -2867,7 +2968,7 @@ LPARAM ScalePenPosition( float zoomLevel, MONITORINFO *monInfo, RECT boundRc, LPARAM extraInfo; extraInfo = GetMessageExtraInfo(); - if( g_PenDown ) { + if( g_PenDown ) { // ignore messages we didn't tag as pen if (extraInfo == MI_WP_SIGNATURE) { @@ -2890,7 +2991,7 @@ LPARAM ScalePenPosition( float zoomLevel, MONITORINFO *monInfo, RECT boundRc, OutputDebug(L"Ignore pen message we didn't send\n"); lParam = 0; } - + } else { if( !GetClipCursor( &rc )) { @@ -2900,7 +3001,7 @@ LPARAM ScalePenPosition( float zoomLevel, MONITORINFO *monInfo, RECT boundRc, OutputDebug( L"Mouse message\n"); } return lParam; -} +} //---------------------------------------------------------------------------- @@ -2927,7 +3028,7 @@ BOOLEAN DrawHighlightedCursor( float ZoomLevel, int Width, int Height ) // InvalidateCursorMoveArea // //---------------------------------------------------------------------------- -void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height, +void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height, POINT currentPt, POINT prevPt, POINT cursorPos ) { int x, y; @@ -2935,7 +3036,7 @@ void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height int invWidth = g_PenWidth + CURSOR_SAVE_MARGIN; if( DrawHighlightedCursor( zoomLevel, width, height ) ) { - + invWidth = g_PenWidth * 3 + 1; } GetZoomedTopLeftCoordinates( zoomLevel, &cursorPos, &x, width, &y, height ); @@ -3058,7 +3159,7 @@ void DrawCursor( HDC hDcTarget, POINT pt, float ZoomLevel, int Width, int Height // //---------------------------------------------------------------------------- void ResizePen( HWND hWnd, HDC hdcScreenCompat, HDC hdcScreenCursorCompat, POINT prevPt, - BOOLEAN g_Tracing, BOOLEAN *g_Drawing, float g_LiveZoomLevel, + BOOLEAN g_Tracing, BOOLEAN *g_Drawing, float g_LiveZoomLevel, BOOLEAN isUser, int newWidth ) { if( !g_Tracing ) { @@ -3117,7 +3218,7 @@ bool IsPenInverted( WPARAM wParam ) //---------------------------------------------------------------------------- // // CaptureScreenshotAsync -// +// // Captures the specified screen using the capture APIs // //---------------------------------------------------------------------------- @@ -3127,7 +3228,7 @@ std::future> CaptureScreenshotAsync(winrt::IDire winrt::com_ptr d3dContext; d3dDevice->GetImmediateContext(d3dContext.put()); - // Creating our frame pool with CreateFreeThreaded means that we + // Creating our frame pool with CreateFreeThreaded means that we // will be called back from the frame pool's internal worker thread // instead of the thread we are currently on. It also disables the // DispatcherQueue requirement. @@ -3165,7 +3266,7 @@ std::future> CaptureScreenshotAsync(winrt::IDire //---------------------------------------------------------------------------- // // CaptureScreenshot -// +// // Captures the specified screen using the capture APIs // //---------------------------------------------------------------------------- @@ -3196,9 +3297,9 @@ winrt::com_ptrCaptureScreenshot(winrt::DirectXPixelFormat const //---------------------------------------------------------------------------- // // CopyD3DTexture -// +// //---------------------------------------------------------------------------- -inline auto CopyD3DTexture(winrt::com_ptr const& device, +inline auto CopyD3DTexture(winrt::com_ptr const& device, winrt::com_ptr const& texture, bool asStagingTexture) { winrt::com_ptr context; @@ -3224,9 +3325,9 @@ inline auto CopyD3DTexture(winrt::com_ptr const& device, //---------------------------------------------------------------------------- // // PrepareStagingTexture -// +// //---------------------------------------------------------------------------- -inline auto PrepareStagingTexture(winrt::com_ptr const& device, +inline auto PrepareStagingTexture(winrt::com_ptr const& device, winrt::com_ptr const& texture) { // If our texture is already set up for staging, then use it. @@ -3244,7 +3345,7 @@ inline auto PrepareStagingTexture(winrt::com_ptr const& device, //---------------------------------------------------------------------------- // // GetBytesPerPixel -// +// //---------------------------------------------------------------------------- inline size_t GetBytesPerPixel(DXGI_FORMAT pixelFormat) @@ -3342,7 +3443,7 @@ GetBytesPerPixel(DXGI_FORMAT pixelFormat) //---------------------------------------------------------------------------- // // CopyBytesFromTexture -// +// //---------------------------------------------------------------------------- inline auto CopyBytesFromTexture(winrt::com_ptr const& texture, uint32_t subresource = 0) { @@ -3395,6 +3496,12 @@ void StopRecording() g_RecordingSession = nullptr; } + if ( g_GifRecordingSession != nullptr ) { + + g_GifRecordingSession->Close(); + g_GifRecordingSession = nullptr; + } + g_RecordToggle = FALSE; #if WINDOWS_CURSOR_RECORDING_WORKAROUND @@ -3424,7 +3531,16 @@ void StopRecording() //---------------------------------------------------------------------------- auto GetUniqueRecordingFilename() { - std::filesystem::path path{ g_RecordingSaveLocation }; + std::filesystem::path path; + + if (g_RecordingFormat == RecordingFormat::GIF) + { + path = g_RecordingSaveLocationGIF; + } + else + { + path = g_RecordingSaveLocation; + } // Chop off index if it's there auto base = std::regex_replace( path.stem().wstring(), std::wregex( L" [(][0-9]+[)]$" ), L"" ); @@ -3442,7 +3558,7 @@ auto GetUniqueRecordingFilename() //---------------------------------------------------------------------------- // // StartRecordingAsync -// +// // Starts the screen recording. // //---------------------------------------------------------------------------- @@ -3451,7 +3567,10 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR auto tempFolderPath = std::filesystem::temp_directory_path().wstring(); auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync( tempFolderPath ); auto appFolder = co_await tempFolder.CreateFolderAsync( L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists ); - auto file = co_await appFolder.CreateFileAsync( L"zoomit.mp4", winrt::CreationCollisionOption::ReplaceExisting ); + + // Choose temp file extension based on format + const wchar_t* tempFileName = (g_RecordingFormat == RecordingFormat::GIF) ? L"zoomit.gif" : L"zoomit.mp4"; + auto file = co_await appFolder.CreateFileAsync( tempFileName, winrt::CreationCollisionOption::ReplaceExisting ); // Get the device auto d3dDevice = util::CreateD3D11Device(); @@ -3460,7 +3579,7 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR // Get the active MONITOR capture device HMONITOR hMon = NULL; - POINT cursorPos = { 0, 0 }; + POINT cursorPos = { 0, 0 }; if( pMonitorFromPoint ) { GetCursorPos( &cursorPos ); @@ -3468,27 +3587,47 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR } winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{ nullptr }; - if( hWndRecord ) + if( hWndRecord ) item = util::CreateCaptureItemForWindow( hWndRecord ); else item = util::CreateCaptureItemForMonitor( hMon ); auto stream = co_await file.OpenAsync( winrt::FileAccessMode::ReadWrite ); - g_RecordingSession = VideoRecordingSession::Create( - g_RecordDevice, - item, - *rcCrop, - g_RecordFrameRate, - g_CaptureAudio, - stream ); - if( g_hWndLiveZoom != NULL ) - g_RecordingSession->EnableCursorCapture( false ); + // Create the appropriate recording session based on format + OutputDebugStringW((L"Starting recording session. Framerate: " + std::to_wstring(g_RecordFrameRate) + L" scaling: " + std::to_wstring(g_RecordScaling) + L" Format: " + (g_RecordingFormat == RecordingFormat::GIF ? L"GIF" : L"MP4") + L"\n").c_str()); + if (g_RecordingFormat == RecordingFormat::GIF) + { + g_GifRecordingSession = GifRecordingSession::Create( + g_RecordDevice, + item, + *rcCrop, + g_RecordFrameRate, + stream ); - co_await g_RecordingSession->StartAsync(); + if( g_hWndLiveZoom != NULL ) + g_GifRecordingSession->EnableCursorCapture( false ); - // g_RecordingSession isn't null if we're aborting a recording - if( g_RecordingSession == nullptr ) { + co_await g_GifRecordingSession->StartAsync(); + } + else + { + g_RecordingSession = VideoRecordingSession::Create( + g_RecordDevice, + item, + *rcCrop, + g_RecordFrameRate, + g_CaptureAudio, + stream ); + + if( g_hWndLiveZoom != NULL ) + g_RecordingSession->EnableCursorCapture( false ); + + co_await g_RecordingSession->StartAsync(); + } + + // Check if recording was aborted + if( g_RecordingSession == nullptr && g_GifRecordingSession == nullptr ) { g_bSaveInProgress = true; @@ -3504,20 +3643,63 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR wil::com_ptr videosItem; if( SUCCEEDED ( SHGetKnownFolderItem( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, IID_IShellItem, (void**) videosItem.put() ) ) ) saveDialog->SetDefaultFolder( videosItem.get() ); - saveDialog->SetDefaultExtension( L".mp4" ); - COMDLG_FILTERSPEC fileTypes[] = { - { L"MP4 Video", L"*.mp4" } - }; - saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); - if( g_RecordingSaveLocation.size() == 0) { + // Set file type based on the recording format + if (g_RecordingFormat == RecordingFormat::GIF) + { + saveDialog->SetDefaultExtension( L".gif" ); + COMDLG_FILTERSPEC fileTypes[] = { + { L"GIF Animation", L"*.gif" } + }; + saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + } + else + { + saveDialog->SetDefaultExtension( L".mp4" ); + COMDLG_FILTERSPEC fileTypes[] = { + { L"MP4 Video", L"*.mp4" } + }; + saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + } + + // Peek the folder Windows has chosen to display + static std::filesystem::path lastSaveFolder; + wil::unique_cotaskmem_string chosenFolderPath; + wil::com_ptr currentSelectedFolder; + bool bFolderChanged = false; + if (SUCCEEDED(saveDialog->GetFolder(currentSelectedFolder.put()))) + { + if (SUCCEEDED(currentSelectedFolder->GetDisplayName(SIGDN_FILESYSPATH, chosenFolderPath.put()))) + { + if (lastSaveFolder != chosenFolderPath.get()) + { + lastSaveFolder = chosenFolderPath.get() ? chosenFolderPath.get() : std::filesystem::path{}; + bFolderChanged = true; + } + } + } + + if( (g_RecordingFormat == RecordingFormat::GIF && g_RecordingSaveLocationGIF.size() == 0) || (g_RecordingFormat == RecordingFormat::MP4 && g_RecordingSaveLocation.size() == 0) || (bFolderChanged)) { wil::com_ptr shellItem; wil::unique_cotaskmem_string folderPath; - if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put()))) - g_RecordingSaveLocation = folderPath.get(); - g_RecordingSaveLocation = std::filesystem::path{ g_RecordingSaveLocation } /= DEFAULT_RECORDING_FILE; + if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put()))) { + if (g_RecordingFormat == RecordingFormat::GIF) { + g_RecordingSaveLocationGIF = folderPath.get(); + std::filesystem::path currentPath{ g_RecordingSaveLocationGIF }; + g_RecordingSaveLocationGIF = currentPath / DEFAULT_GIF_RECORDING_FILE; + } + else { + g_RecordingSaveLocation = folderPath.get(); + if (g_RecordingFormat == RecordingFormat::MP4) { + std::filesystem::path currentPath{ g_RecordingSaveLocation }; + g_RecordingSaveLocation = currentPath / DEFAULT_RECORDING_FILE; + } + } + } } + + // Always use appropriate default filename based on current format auto suggestedName = GetUniqueRecordingFilename(); saveDialog->SetFileName( suggestedName.c_str() ); @@ -3545,9 +3727,15 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR } else { - co_await file.MoveAndReplaceAsync( destFile ); - g_RecordingSaveLocation = file.Path(); - SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd); + co_await file.MoveAndReplaceAsync(destFile); + if (g_RecordingFormat == RecordingFormat::GIF) { + g_RecordingSaveLocationGIF = file.Path(); + SaveToClipboard(g_RecordingSaveLocationGIF.c_str(), hWnd); + } + else { + g_RecordingSaveLocation = file.Path(); + SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd); + } } g_bSaveInProgress = false; @@ -3566,6 +3754,7 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR } co_await file.DeleteAsync(); g_RecordingSession = nullptr; + g_GifRecordingSession = nullptr; } } catch( const winrt::hresult_error& error ) { @@ -3681,9 +3870,9 @@ void ShowMainWindow(HWND hWnd, const MONITORINFO& monInfo, int width, int height // //---------------------------------------------------------------------------- LRESULT APIENTRY MainWndProc( - HWND hWnd, + HWND hWnd, UINT message, - WPARAM wParam, + WPARAM wParam, LPARAM lParam) { static int width, height; @@ -3742,7 +3931,7 @@ LRESULT APIENTRY MainWndProc( #endif bool isCaptureSupported = false; RECT rc, rc1; - PAINTSTRUCT ps; + PAINTSTRUCT ps; TCHAR timerText[16]; TCHAR negativeTimerText[16]; BOOLEAN penInverted; @@ -3754,6 +3943,7 @@ LRESULT APIENTRY MainWndProc( OPENFILENAME openFileName; static TCHAR filePath[MAX_PATH] = {L"zoomit"}; NOTIFYICONDATA tNotifyIconData; + static DWORD64 g_TelescopingZoomLastTick = 0ull; const auto drawAllRightJustifiedLines = [&rc]( long lineHeight, bool doPop = false ) { rc.top = textPt.y - static_cast(g_TextBufferPreviousLines.size()) * lineHeight; @@ -3780,19 +3970,119 @@ LRESULT APIENTRY MainWndProc( } }; + const auto doTelescopingZoomTimer = [hWnd, wParam, lParam, &x, &y]( bool invalidate = true ) { + if( zoomTelescopeStep != 0.0f ) + { + zoomLevel *= zoomTelescopeStep; + g_TelescopingZoomLastTick = GetTickCount64(); + if( (zoomTelescopeStep > 1 && zoomLevel >= zoomTelescopeTarget) || + (zoomTelescopeStep < 1 && zoomLevel <= zoomTelescopeTarget) ) + { + zoomLevel = zoomTelescopeTarget; + + g_TelescopingZoomLastTick = 0ull; + KillTimer( hWnd, wParam ); + OutputDebug( L"SETCURSOR mon_left: %x mon_top: %x x: %d y: %d\n", + monInfo.rcMonitor.left, + monInfo.rcMonitor.top, + cursorPos.x, + cursorPos.y ); + SetCursorPos( monInfo.rcMonitor.left + cursorPos.x, + monInfo.rcMonitor.top + cursorPos.y ); + } + } + else + { + // Case where we didn't zoom at all + g_TelescopingZoomLastTick = 0ull; + KillTimer( hWnd, wParam ); + } + if( wParam == 2 && zoomLevel == 1 ) + { + g_Zoomed = FALSE; + if( g_ZoomOnLiveZoom ) + { + GetCursorPos( &cursorPos ); + cursorPos = ScalePointInRects( cursorPos, monInfo.rcMonitor, g_LiveZoomSourceRect ); + SetCursorPos( cursorPos.x, cursorPos.y ); + SendMessage( hWnd, WM_HOTKEY, LIVE_HOTKEY, 0 ); + } + else if( lParam != SHALLOW_ZOOM ) + { + // Figure out where final unzoomed cursor should be + if( g_Drawing ) + { + cursorPos = prevPt; + } + OutputDebug( L"FINAL MOUSE: x: %d y: %d\n", cursorPos.x, cursorPos.y ); + GetZoomedTopLeftCoordinates( zoomLevel, &cursorPos, &x, width, &y, height ); + cursorPos.x = monInfo.rcMonitor.left + x + static_cast((cursorPos.x - x) * zoomLevel); + cursorPos.y = monInfo.rcMonitor.top + y + static_cast((cursorPos.y - y) * zoomLevel); + SetCursorPos( cursorPos.x, cursorPos.y ); + } + if( hTargetWindow ) + { + SetWindowPos( hTargetWindow, HWND_BOTTOM, rcTargetWindow.left, rcTargetWindow.top, rcTargetWindow.right - rcTargetWindow.left, rcTargetWindow.bottom - rcTargetWindow.top, 0 ); + hTargetWindow = NULL; + } + DeleteDrawUndoList( &drawUndoList ); + + // Restore live zoom if we came from that mode + if( g_ZoomOnLiveZoom ) + { + SendMessage( g_hWndLiveZoom, WM_USER_SET_ZOOM, static_cast(g_LiveZoomLevel), reinterpret_cast(&g_LiveZoomSourceRect) ); + g_ZoomOnLiveZoom = FALSE; + forcePenResize = TRUE; + } + + SetForegroundWindow( g_ActiveWindow ); + ClipCursor( NULL ); + g_HaveDrawn = FALSE; + g_TypeMode = TypeModeOff; + g_HaveTyped = FALSE; + g_Drawing = FALSE; + EnableDisableStickyKeys( TRUE ); + DeleteObject( hTypingFont ); + DeleteDC( hdcScreen ); + DeleteDC( hdcScreenCompat ); + DeleteDC( hdcScreenCursorCompat ); + DeleteDC( hdcScreenSaveCompat ); + DeleteObject( hbmpCompat ); + DeleteObject (hbmpCursorCompat ); + DeleteObject( hbmpDrawingCompat ); + DeleteObject( hDrawingPen ); + + SetFocus( g_ActiveWindow ); + ShowWindow( hWnd, SW_HIDE ); + } + if( invalidate ) + { + InvalidateRect( hWnd, NULL, FALSE ); + } + }; + switch (message) { case WM_CREATE: // get default font - GetObject( GetStockObject(DEFAULT_GUI_FONT), sizeof g_LogFont, &g_LogFont ); + GetObject( GetStockObject(DEFAULT_GUI_FONT), sizeof g_LogFont, &g_LogFont ); g_LogFont.lfWeight = FW_NORMAL; hDc = CreateCompatibleDC( NULL ); g_LogFont.lfHeight = -MulDiv(8, GetDeviceCaps(hDc, LOGPIXELSY), 72); DeleteDC( hDc ); reg.ReadRegSettings( RegSettings ); - - // to support migrating from + + // Set g_RecordScaling based on the current recording format + if (g_RecordingFormat == RecordingFormat::GIF) { + g_RecordScaling = g_RecordScalingGIF; + g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE; + } else { + g_RecordScaling = g_RecordScalingMP4; + g_RecordFrameRate = RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE; + } + + // to support migrating from if ((g_PenColor >> 24) == 0) { g_PenColor |= 0xFF << 24; } @@ -3822,7 +4112,7 @@ LRESULT APIENTRY MainWndProc( APPNAME, MB_ICONERROR ); showOptions = TRUE; - } else if( g_LiveZoomToggleKey && + } else if( g_LiveZoomToggleKey && (!RegisterHotKey( hWnd, LIVE_HOTKEY, g_LiveZoomToggleMod, g_LiveZoomToggleKey & 0xFF) || !RegisterHotKey(hWnd, LIVE_DRAW_HOTKEY, (g_LiveZoomToggleMod ^ MOD_SHIFT), g_LiveZoomToggleKey & 0xFF))) { @@ -3844,7 +4134,7 @@ LRESULT APIENTRY MainWndProc( showOptions = TRUE; } - else if( g_DemoTypeToggleKey && + else if( g_DemoTypeToggleKey && (!RegisterHotKey( hWnd, DEMOTYPE_HOTKEY, g_DemoTypeToggleMod, g_DemoTypeToggleKey & 0xFF ) || !RegisterHotKey(hWnd, DEMOTYPE_RESET_HOTKEY, (g_DemoTypeToggleMod ^ MOD_SHIFT), g_DemoTypeToggleKey & 0xFF))) { @@ -3853,7 +4143,7 @@ LRESULT APIENTRY MainWndProc( showOptions = TRUE; } - else if (g_SnipToggleKey && + else if (g_SnipToggleKey && (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) || !RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))) { @@ -3862,7 +4152,7 @@ LRESULT APIENTRY MainWndProc( showOptions = TRUE; } - else if (g_RecordToggleKey && + else if (g_RecordToggleKey && (!RegisterHotKey(hWnd, RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) || !RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, (g_RecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) || !RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, (g_RecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF))) { @@ -3985,7 +4275,7 @@ LRESULT APIENTRY MainWndProc( // Highlight is not supported in LiveDraw g_PenColor |= 0xFF << 24; } - } + } break; case SNIP_SAVE_HOTKEY: @@ -4156,7 +4446,7 @@ LRESULT APIENTRY MainWndProc( if( g_hWndLiveZoom == NULL ) { OutputDebug(L"Create LIVEZOOM\n"); g_hWndLiveZoom = CreateWindowEx( WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TRANSPARENT, - L"MagnifierClass", L"ZoomIt Live Zoom", + L"MagnifierClass", L"ZoomIt Live Zoom", WS_POPUP | WS_CLIPSIBLINGS, 0, 0, 0, 0, NULL, NULL, g_hInstance, static_cast(GetForegroundWindow()) ); pSetLayeredWindowAttributes( hWnd, 0, 0, LWA_ALPHA ); @@ -4179,10 +4469,10 @@ LRESULT APIENTRY MainWndProc( g_LiveZoomLevel = g_ZoomLevels[g_SliderZoomLevel]; #endif // Unzoom - SendMessage( g_hWndLiveZoom, WM_KEYDOWN, VK_ESCAPE, 0 ); + SendMessage( g_hWndLiveZoom, WM_KEYDOWN, VK_ESCAPE, 0 ); } else { - + OutputDebug(L"Show liveZoom\n"); ShowWindow( g_hWndLiveZoom, SW_SHOW ); } @@ -4206,6 +4496,8 @@ LRESULT APIENTRY MainWndProc( case RECORD_HOTKEY: case RECORD_CROP_HOTKEY: case RECORD_WINDOW_HOTKEY: + case RECORD_GIF_HOTKEY: + case RECORD_GIF_WINDOW_HOTKEY: // // Recording @@ -4221,7 +4513,7 @@ LRESULT APIENTRY MainWndProc( if( g_RecordCropping == TRUE ) { break; - } + } // Start screen recording try @@ -4243,7 +4535,7 @@ LRESULT APIENTRY MainWndProc( { // Already recording break; - } + } g_RecordCropping = TRUE; @@ -4334,8 +4626,8 @@ LRESULT APIENTRY MainWndProc( { cropRc = {}; - // if we're recording a window, get the window - if (wParam == RECORD_WINDOW_HOTKEY) + // if we're recording a window, get the window + if (wParam == RECORD_WINDOW_HOTKEY || wParam == RECORD_GIF_WINDOW_HOTKEY) { GetCursorPos(&cursorPos); hWndRecord = WindowFromPoint(cursorPos); @@ -4353,6 +4645,7 @@ LRESULT APIENTRY MainWndProc( if( g_RecordToggle == FALSE ) { g_RecordToggle = TRUE; + #ifdef __ZOOMIT_POWERTOYS__ if( g_StartedByPowerToys ) { @@ -4379,7 +4672,7 @@ LRESULT APIENTRY MainWndProc( break; } - + OutputDebug( L"ZOOM HOTKEY: %d\n", lParam); if( g_TimerActive ) { @@ -4444,9 +4737,9 @@ LRESULT APIENTRY MainWndProc( // Get screen DCs hdcScreen = CreateDC(L"DISPLAY", static_cast(NULL), static_cast(NULL), static_cast(NULL)); - hdcScreenCompat = CreateCompatibleDC(hdcScreen); - hdcScreenSaveCompat = CreateCompatibleDC(hdcScreen); - hdcScreenCursorCompat = CreateCompatibleDC(hdcScreen); + hdcScreenCompat = CreateCompatibleDC(hdcScreen); + hdcScreenSaveCompat = CreateCompatibleDC(hdcScreen); + hdcScreenCursorCompat = CreateCompatibleDC(hdcScreen); // Determine what monitor we're on GetCursorPos(&cursorPos); @@ -4461,13 +4754,13 @@ LRESULT APIENTRY MainWndProc( bmp.bmPlanes = static_cast(GetDeviceCaps(hdcScreen, PLANES)); bmp.bmWidth = width; bmp.bmHeight = height; - bmp.bmWidthBytes = ((bmp.bmWidth + 15) &~15)/8; - hbmpCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, + bmp.bmWidthBytes = ((bmp.bmWidth + 15) &~15)/8; + hbmpCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, bmp.bmPlanes, bmp.bmBitsPixel, static_cast(NULL)); - SelectObject(hdcScreenCompat, hbmpCompat); + SelectObject(hdcScreenCompat, hbmpCompat); // Create saved bitmap - hbmpDrawingCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, + hbmpDrawingCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, bmp.bmPlanes, bmp.bmBitsPixel, static_cast(NULL)); SelectObject(hdcScreenSaveCompat, hbmpDrawingCompat); @@ -4542,7 +4835,7 @@ LRESULT APIENTRY MainWndProc( g_TypeMode = TypeModeOff; g_HaveDrawn = FALSE; EnableDisableStickyKeys( TRUE ); - + // Go full screen g_ActiveWindow = GetForegroundWindow(); OutputDebug( L"active window: %x\n", PtrToLong(g_ActiveWindow) ); @@ -4552,7 +4845,7 @@ LRESULT APIENTRY MainWndProc( OutputDebug(L"Calling ShowMainWindow\n"); ShowMainWindow(hWnd, monInfo, width, height); } - + // Start telescoping zoom. Lparam is non-zero if this // was a real hotkey and not the message we send ourself to enter // unzoomed drawing mode. @@ -4570,7 +4863,7 @@ LRESULT APIENTRY MainWndProc( OutputDebug(L"Enter liveZoom draw\n"); g_LiveZoomSourceRect = *reinterpret_cast(SendMessage( g_hWndLiveZoom, WM_USER_GET_SOURCE_RECT, 0, 0 )); g_LiveZoomLevel = *reinterpret_cast(SendMessage(g_hWndLiveZoom, WM_USER_GET_ZOOM_LEVEL, 0, 0)); - + // Set live zoom level to 1 in preparation of us being full screen static zoomLevel = 1.0; zoomTelescopeTarget = 1.0; @@ -4613,8 +4906,11 @@ LRESULT APIENTRY MainWndProc( zoomTelescopeStep = ZOOM_LEVEL_STEP_IN; zoomTelescopeTarget = g_ZoomLevels[g_SliderZoomLevel]; - if( g_AnimateZoom ) - zoomLevel = static_cast(1.0) * zoomTelescopeStep; + if( g_AnimateZoom ) + { + zoomLevel = static_cast(1.0) * zoomTelescopeStep; + g_TelescopingZoomLastTick = GetTickCount64(); + } else zoomLevel = zoomTelescopeTarget; SetTimer( hWnd, 1, ZOOM_LEVEL_STEP_TIME, NULL ); @@ -4629,9 +4925,10 @@ LRESULT APIENTRY MainWndProc( if( lParam != SHALLOW_DESTROY && !g_ZoomOnLiveZoom && g_AnimateZoom && g_TelescopeZoomOut && zoomTelescopeTarget != 1 ) { - // Start telescoping zoom. + // Start telescoping zoom. zoomTelescopeStep = ZOOM_LEVEL_STEP_OUT; zoomTelescopeTarget = 1.0; + g_TelescopingZoomLastTick = GetTickCount64(); SetTimer( hWnd, 2, ZOOM_LEVEL_STEP_TIME, NULL ); } else { @@ -4682,7 +4979,7 @@ LRESULT APIENTRY MainWndProc( } break; - case WM_POINTERDOWN: + case WM_POINTERDOWN: OutputDebug(L"WM_POINTERDOWN\n"); penInverted = IsPenInverted(wParam); if (!penInverted) { @@ -4719,15 +5016,15 @@ LRESULT APIENTRY MainWndProc( // // Zoom or modify break timer // - if( GET_WHEEL_DELTA_WPARAM(wParam) < 0 ) + if( GET_WHEEL_DELTA_WPARAM(wParam) < 0 ) wParam -= (WHEEL_DELTA-1) << 16; - else + else wParam += (WHEEL_DELTA-1) << 16; delta = GET_WHEEL_DELTA_WPARAM(wParam)/WHEEL_DELTA; - OutputDebug( L"mousewheel: wParam: %d delta: %d\n", + OutputDebug( L"mousewheel: wParam: %d delta: %d\n", GET_WHEEL_DELTA_WPARAM(wParam), delta ); if( g_Zoomed ) { - + if( g_TypeMode == TypeModeOff ) { if( g_Drawing && (LOWORD( wParam ) & MK_CONTROL) ) { @@ -4746,7 +5043,7 @@ LRESULT APIENTRY MainWndProc( while( delta-- ) { if( zoomIn ) { - + if( zoomTelescopeTarget < ZOOM_LEVEL_MAX ) { if( zoomTelescopeTarget < 2 ) { @@ -4754,17 +5051,17 @@ LRESULT APIENTRY MainWndProc( zoomTelescopeTarget = 2; } else { - + // Start telescoping zoom - zoomTelescopeTarget = zoomTelescopeTarget * 2; + zoomTelescopeTarget = zoomTelescopeTarget * 2; } - zoomTelescopeStep = ZOOM_LEVEL_STEP_IN; - if( g_AnimateZoom ) - zoomLevel *= zoomTelescopeStep; + zoomTelescopeStep = ZOOM_LEVEL_STEP_IN; + if( g_AnimateZoom ) + zoomLevel *= zoomTelescopeStep; else zoomLevel = zoomTelescopeTarget; - if( zoomLevel > zoomTelescopeTarget ) + if( zoomLevel > zoomTelescopeTarget ) zoomLevel = zoomTelescopeTarget; else SetTimer( hWnd, 1, ZOOM_LEVEL_STEP_TIME, NULL ); @@ -4775,17 +5072,17 @@ LRESULT APIENTRY MainWndProc( // Let them more gradually zoom out from 2x to 1x if( zoomTelescopeTarget <= 2 ) { - zoomTelescopeTarget *= .75; - if( zoomTelescopeTarget < ZOOM_LEVEL_MIN ) + zoomTelescopeTarget *= .75; + if( zoomTelescopeTarget < ZOOM_LEVEL_MIN ) zoomTelescopeTarget = ZOOM_LEVEL_MIN; } else { - zoomTelescopeTarget = zoomTelescopeTarget/2; + zoomTelescopeTarget = zoomTelescopeTarget/2; } - zoomTelescopeStep = ZOOM_LEVEL_STEP_OUT; - if( g_AnimateZoom ) - zoomLevel *= zoomTelescopeStep; + zoomTelescopeStep = ZOOM_LEVEL_STEP_OUT; + if( g_AnimateZoom ) + zoomLevel *= zoomTelescopeStep; else zoomLevel = zoomTelescopeTarget; @@ -4809,11 +5106,11 @@ LRESULT APIENTRY MainWndProc( RestoreCursorArea( hdcScreenCompat, hdcScreenCursorCompat, prevPt ); } - //SetCursorPos( monInfo.rcMonitor.left + cursorPos.x, + //SetCursorPos( monInfo.rcMonitor.left + cursorPos.x, // monInfo.rcMonitor.top + cursorPos.y ); } InvalidateRect( hWnd, NULL, FALSE ); - } + } } } else { @@ -4827,7 +5124,7 @@ LRESULT APIENTRY MainWndProc( // Set lParam to 0 as part of message to keyup hander DeleteObject(hTypingFont); g_LogFont.lfHeight = max((int)(height / zoomLevel) / g_FontScale, 12); - if (g_LogFont.lfHeight < 20) + if (g_LogFont.lfHeight < 20) g_LogFont.lfQuality = NONANTIALIASED_QUALITY; else g_LogFont.lfQuality = ANTIALIASED_QUALITY; @@ -4934,7 +5231,7 @@ LRESULT APIENTRY MainWndProc( OutputDebug(L"Entering typing mode and resetting cursor position\n"); SendMessage( hWnd, WM_LBUTTONDOWN, 0, MAKELPARAM( cursorPos.x, cursorPos.y)); - } + } // Do they want to right-justify text? OutputDebug(L"Keyup Shift: %x\n", GetAsyncKeyState(VK_SHIFT)); @@ -4964,13 +5261,13 @@ LRESULT APIENTRY MainWndProc( g_LogFont.lfQuality = ANTIALIASED_QUALITY; hTypingFont = CreateFontIndirect( &g_LogFont ); SelectObject( hdcScreenCompat, hTypingFont ); - + // If lparam == 0 that means that we sent the message as part of a font resize if( g_Drawing && lParam != 0) { RestoreCursorArea( hdcScreenCompat, hdcScreenCursorCompat, prevPt ); PushDrawUndo( hdcScreenCompat, &drawUndoList, width, height ); - + } else if( !g_Drawing ) { textPt = cursorPos; @@ -4985,7 +5282,7 @@ LRESULT APIENTRY MainWndProc( case WM_KEYDOWN: if( (g_TypeMode != TypeModeOff) && g_HaveTyped && static_cast(wParam) != VK_UP && static_cast(wParam) != VK_DOWN && - (isprint( static_cast(wParam)) || + (isprint( static_cast(wParam)) || wParam == VK_RETURN || wParam == VK_DELETE || wParam == VK_BACK )) { if( wParam == VK_RETURN ) { @@ -5077,10 +5374,10 @@ LRESULT APIENTRY MainWndProc( } DrawTypingCursor( hWnd, &textPt, hdcScreenCompat, hdcScreenCursorCompat, &cursorRc ); } - } + } break; } - switch (wParam) { + switch (wParam) { case 'R': case 'B': case 'Y': @@ -5089,7 +5386,7 @@ LRESULT APIENTRY MainWndProc( case 'X': case 'P': if( (g_Zoomed || g_TimerActive) && (g_TypeMode == TypeModeOff)) { - + PDWORD penColor; if( g_TimerActive ) penColor = &g_BreakPenColor; @@ -5141,12 +5438,12 @@ LRESULT APIENTRY MainWndProc( SelectObject( hdcScreenCompat, hDrawingPen ); if( g_Drawing ) { - SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); - + SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); + } else if( g_TimerActive ) { - - InvalidateRect( hWnd, NULL, FALSE ); - + + InvalidateRect( hWnd, NULL, FALSE ); + } else if( g_TypeMode != TypeModeOff ) { ClearTypingCursor( hdcScreenCompat, hdcScreenCursorCompat, cursorRc, g_BlankedScreen ); @@ -5160,11 +5457,11 @@ LRESULT APIENTRY MainWndProc( if( (GetKeyState( VK_CONTROL ) & 0x8000 ) && g_HaveDrawn && !g_Tracing ) { if( PopDrawUndo( hdcScreenCompat, &drawUndoList, width, height )) { - + if( g_Drawing ) { SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, prevPt ); - SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); + SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); } else { @@ -5180,7 +5477,7 @@ LRESULT APIENTRY MainWndProc( SetCursorPos( boundRc.left + (boundRc.right - boundRc.left)/2, boundRc.top + (boundRc.bottom - boundRc.top)/2 ); - SendMessage( hWnd, WM_MOUSEMOVE, 0, + SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( (boundRc.right - boundRc.left)/2, (boundRc.bottom - boundRc.top)/2 )); } @@ -5215,7 +5512,7 @@ LRESULT APIENTRY MainWndProc( // Save area that's going to be occupied by new cursor position SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, prevPt ); - SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); + SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); } break; @@ -5243,18 +5540,18 @@ LRESULT APIENTRY MainWndProc( } InvalidateRect( hWnd, NULL, FALSE ); g_BlankedScreen = FALSE; - } + } break; case VK_UP: - SendMessage( hWnd, WM_MOUSEWHEEL, - MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 || GetAsyncKeyState( VK_RCONTROL ) != 0 ? + SendMessage( hWnd, WM_MOUSEWHEEL, + MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 || GetAsyncKeyState( VK_RCONTROL ) != 0 ? MK_CONTROL: 0, WHEEL_DELTA), 0 ); return TRUE; case VK_DOWN: - SendMessage( hWnd, WM_MOUSEWHEEL, - MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 || GetAsyncKeyState( VK_RCONTROL ) != 0 ? + SendMessage( hWnd, WM_MOUSEWHEEL, + MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 || GetAsyncKeyState( VK_RCONTROL ) != 0 ? MK_CONTROL: 0, -WHEEL_DELTA), 0 ); return TRUE; @@ -5273,13 +5570,13 @@ LRESULT APIENTRY MainWndProc( InvalidateRect( hWnd, NULL, TRUE ); } break; - - case VK_ESCAPE: + + case VK_ESCAPE: if( g_TypeMode != TypeModeOff) { // Turn off SendMessage( hWnd, WM_USER_TYPING_OFF, 0, 0 ); - + } else { forcePenResize = TRUE; @@ -5308,6 +5605,14 @@ LRESULT APIENTRY MainWndProc( g_Zoomed, g_Drawing, g_Tracing); OutputDebug(L"Window visible: %d Topmost: %d\n", IsWindowVisible(hWnd), GetWindowLong(hWnd, GWL_EXSTYLE)& WS_EX_TOPMOST); + if( g_Zoomed && g_TelescopingZoomLastTick != 0ull && !g_Drawing && !g_Tracing ) + { + ULONG64 now = GetTickCount64(); + if( now - g_TelescopingZoomLastTick >= ZOOM_LEVEL_STEP_TIME ) + { + doTelescopingZoomTimer( false ); + } + } if( g_Zoomed && (g_TypeMode == TypeModeOff) && !g_bSaveInProgress ) { @@ -5318,7 +5623,7 @@ LRESULT APIENTRY MainWndProc( POINT currentPt; // Are we in pen mode on a tablet? - lParam = ScalePenPosition( zoomLevel, &monInfo, boundRc, message, lParam); + lParam = ScalePenPosition( zoomLevel, &monInfo, boundRc, message, lParam); currentPt.x = LOWORD(lParam); currentPt.y = HIWORD(lParam); @@ -5330,10 +5635,10 @@ LRESULT APIENTRY MainWndProc( } else if(g_DrawingShape) { - SetROP2(hdcScreenCompat, R2_NOTXORPEN); - - // If a previous target rectangle exists, erase - // it by drawing another rectangle on top. + SetROP2(hdcScreenCompat, R2_NOTXORPEN); + + // If a previous target rectangle exists, erase + // it by drawing another rectangle on top. if( g_rcRectangle.top != g_rcRectangle.bottom || g_rcRectangle.left != g_rcRectangle.right ) { @@ -5365,12 +5670,12 @@ LRESULT APIENTRY MainWndProc( } } - // Save the coordinates of the target rectangle. + // Save the coordinates of the target rectangle. // Avoid invalid rectangles by ensuring that the - // value of the left coordinate is greater than - // that of the right, and that the value of the - // bottom coordinate is greater than that of - // the top. + // value of the left coordinate is greater than + // that of the right, and that the value of the + // bottom coordinate is greater than that of + // the top. if( g_DrawingShape == DRAW_LINE || g_DrawingShape == DRAW_ARROW ) { @@ -5379,36 +5684,36 @@ LRESULT APIENTRY MainWndProc( } else { - if ((g_RectangleAnchor.x < currentPt.x) && + if ((g_RectangleAnchor.x < currentPt.x) && (g_RectangleAnchor.y > currentPt.y)) { - SetRect(&g_rcRectangle, g_RectangleAnchor.x, currentPt.y, - currentPt.x, g_RectangleAnchor.y); + SetRect(&g_rcRectangle, g_RectangleAnchor.x, currentPt.y, + currentPt.x, g_RectangleAnchor.y); - } else if ((g_RectangleAnchor.x > currentPt.x) && + } else if ((g_RectangleAnchor.x > currentPt.x) && (g_RectangleAnchor.y > currentPt.y )) { - SetRect(&g_rcRectangle, currentPt.x, - currentPt.y, g_RectangleAnchor.x,g_RectangleAnchor.y); + SetRect(&g_rcRectangle, currentPt.x, + currentPt.y, g_RectangleAnchor.x,g_RectangleAnchor.y); - } else if ((g_RectangleAnchor.x > currentPt.x) && + } else if ((g_RectangleAnchor.x > currentPt.x) && (g_RectangleAnchor.y < currentPt.y )) { - SetRect(&g_rcRectangle, currentPt.x, g_RectangleAnchor.y, - g_RectangleAnchor.x, currentPt.y ); + SetRect(&g_rcRectangle, currentPt.x, g_RectangleAnchor.y, + g_RectangleAnchor.x, currentPt.y ); } else { - SetRect(&g_rcRectangle, g_RectangleAnchor.x, g_RectangleAnchor.y, - currentPt.x, currentPt.y ); + SetRect(&g_rcRectangle, g_RectangleAnchor.x, g_RectangleAnchor.y, + currentPt.x, currentPt.y ); } } if (g_rcRectangle.left != g_rcRectangle.right || g_rcRectangle.top != g_rcRectangle.bottom) { - // Draw the new target rectangle. + // Draw the new target rectangle. 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, + OutputDebug(L"SHAPE: (%d, %d) - (%d, %d)\n", g_rcRectangle.left, g_rcRectangle.top, g_rcRectangle.right, g_rcRectangle.bottom); } @@ -5446,7 +5751,7 @@ LRESULT APIENTRY MainWndProc( BYTE* pPixels = static_cast(lineData->Scan0); // Create a GDI bitmap that's the size of the lineBounds rectangle - Gdiplus::Bitmap *blurBitmap = CreateGdiplusBitmap( hdcScreenCompat, // oldestUndo->hDc, + Gdiplus::Bitmap *blurBitmap = CreateGdiplusBitmap( hdcScreenCompat, // oldestUndo->hDc, lineBounds.X, lineBounds.Y, lineBounds.Width, lineBounds.Height); // Blur it @@ -5466,8 +5771,8 @@ LRESULT APIENTRY MainWndProc( // Draw new cursor DrawCursor(hdcScreenCompat, currentPt, zoomLevel, width, height); - } - else if(PEN_COLOR_HIGHLIGHT(g_PenColor)) { + } + else if(PEN_COLOR_HIGHLIGHT(g_PenColor)) { OutputDebug(L"HIGHLIGHT\n"); @@ -5502,7 +5807,7 @@ LRESULT APIENTRY MainWndProc( HDC hdcDIBOrig; HBITMAP hDibOrigBitmap, hDibBitmap; P_DRAW_UNDO oldestUndo = GetOldestUndo(drawUndoList); - BYTE* pDestPixels2 = CreateBitmapMemoryDIB(hdcScreenCompat, oldestUndo->hDc, &lineBounds, + BYTE* pDestPixels2 = CreateBitmapMemoryDIB(hdcScreenCompat, oldestUndo->hDc, &lineBounds, &hdcDIBOrig, &hDibBitmap, &hDibOrigBitmap); for (int local_y = 0; local_y < lineBounds.Height; ++local_y) { @@ -5559,7 +5864,7 @@ LRESULT APIENTRY MainWndProc( // Restore area where cursor was previously RestoreCursorArea( hdcScreenCompat, hdcScreenCursorCompat, prevPt ); - + // Save area that's going to be occupied by new cursor position SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, currentPt ); @@ -5608,12 +5913,12 @@ LRESULT APIENTRY MainWndProc( #if 0 { static int index = 0; - OutputDebug( L"%d: foreground: %x focus: %x (hwnd: %x)\n", + OutputDebug( L"%d: foreground: %x focus: %x (hwnd: %x)\n", index++, (DWORD) PtrToUlong(GetForegroundWindow()), PtrToUlong(GetFocus()), PtrToUlong(hWnd)); } #endif return TRUE; - + case WM_LBUTTONDOWN: g_StraightDirection = 0; @@ -5626,10 +5931,10 @@ LRESULT APIENTRY MainWndProc( RestoreCursorArea( hdcScreenCompat, hdcScreenCursorCompat, prevPt ); } - + // don't push undo if we sent this to ourselves for a pen resize if( wParam != -1 ) { - + PushDrawUndo( hdcScreenCompat, &drawUndoList, width, height ); } else { @@ -5658,7 +5963,7 @@ LRESULT APIENTRY MainWndProc( if( wParam & MK_SHIFT && wParam & MK_CONTROL ) g_DrawingShape = DRAW_ARROW; - else if( wParam & MK_CONTROL ) + else if( wParam & MK_CONTROL ) g_DrawingShape = DRAW_RECTANGLE; else if( wParam & MK_SHIFT ) g_DrawingShape = DRAW_LINE; @@ -5666,8 +5971,8 @@ LRESULT APIENTRY MainWndProc( g_DrawingShape = DRAW_ELLIPSE; g_RectangleAnchor.x = LOWORD(lParam); g_RectangleAnchor.y = HIWORD(lParam); - SetRect(&g_rcRectangle, g_RectangleAnchor.x, g_RectangleAnchor.y, - g_RectangleAnchor.x, g_RectangleAnchor.y); + SetRect(&g_rcRectangle, g_RectangleAnchor.x, g_RectangleAnchor.y, + g_RectangleAnchor.x, g_RectangleAnchor.y); } else { @@ -5685,10 +5990,10 @@ LRESULT APIENTRY MainWndProc( } g_Tracing = TRUE; SetROP2( hdcScreenCompat, R2_COPYPEN ); - prevPt.x = LOWORD(lParam); - prevPt.y = HIWORD(lParam); + prevPt.x = LOWORD(lParam); + prevPt.y = HIWORD(lParam); g_HaveDrawn = TRUE; - + } else { OutputDebug(L"Tracing on\n"); @@ -5779,7 +6084,7 @@ LRESULT APIENTRY MainWndProc( return TRUE; case WM_LBUTTONUP: - OutputDebug(L"LBUTTONUP: zoomed: %d drawing: %d tracing: %d\n", + OutputDebug(L"LBUTTONUP: zoomed: %d drawing: %d tracing: %d\n", g_Zoomed, g_Drawing, g_Tracing); if( g_Zoomed && g_Drawing && g_Tracing ) { @@ -5800,12 +6105,12 @@ LRESULT APIENTRY MainWndProc( if( g_StraightDirection == -1 ) { adjustPos.x = prevPt.x; - + } else { adjustPos.y = prevPt.y; } - lParam = MAKELPARAM( adjustPos.x, adjustPos.y ); + lParam = MAKELPARAM( adjustPos.x, adjustPos.y ); if( !g_DrawingShape ) { @@ -5843,7 +6148,7 @@ LRESULT APIENTRY MainWndProc( } SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, prevPt ); DrawCursor( hdcScreenCompat, prevPt, zoomLevel, width, height ); - + } else if (g_rcRectangle.top != g_rcRectangle.bottom || g_rcRectangle.left != g_rcRectangle.right ) { @@ -5912,18 +6217,18 @@ LRESULT APIENTRY MainWndProc( DeleteTypedText( &typedKeyList ); // 1 means don't reset the cursor. We get that for font resizing - // Only move the cursor if we're drawing, because otherwise the screen moves to center + // Only move the cursor if we're drawing, because otherwise the screen moves to center // on the new cursor position if( wParam != 1 && g_Drawing ) { prevPt.x = cursorRc.left; prevPt.y = cursorRc.top; - SetCursorPos( monInfo.rcMonitor.left + prevPt.x, + SetCursorPos( monInfo.rcMonitor.left + prevPt.x, monInfo.rcMonitor.top + prevPt.y ); SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, prevPt ); SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); - + } else if( !g_Drawing) { // FIX: would be nice to reset cursor so screen doesn't move @@ -5969,12 +6274,12 @@ LRESULT APIENTRY MainWndProc( TrackPopupMenu( hPopupMenu, 0, pt.x , pt.y, 0, hWnd, NULL ); DestroyMenu( hPopupMenu ); break; - } + } case WM_LBUTTONDBLCLK: if( !g_TimerActive ) { SendMessage( hWnd, WM_COMMAND, IDC_OPTIONS, 0 ); - + } else { SetForegroundWindow( hWnd ); @@ -6066,6 +6371,17 @@ LRESULT APIENTRY MainWndProc( { // Reload the settings. This message is called from PowerToys after a setting is changed by the user. reg.ReadRegSettings(RegSettings); + + if (g_RecordingFormat == RecordingFormat::GIF) + { + g_RecordScaling = g_RecordScalingGIF; + g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE; + } + else + { + g_RecordScaling = g_RecordScalingMP4; + g_RecordFrameRate = RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE; + } // Apply tray icon setting EnableDisableTrayIcon(hWnd, g_ShowTrayIcon); @@ -6147,6 +6463,13 @@ LRESULT APIENTRY MainWndProc( showOptions = TRUE; } } + // Register CTRL+8 for GIF recording and CTRL+ALT+8 for GIF window recording + if (!RegisterHotKey(hWnd, RECORD_GIF_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, '8') || + !RegisterHotKey(hWnd, RECORD_GIF_WINDOW_HOTKEY, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, '8')) + { + MessageBox(hWnd, L"The specified GIF recording hotkey is already in use.\nSelect a different GIF recording hotkey.", APPNAME, MB_ICONERROR); + showOptions = TRUE; + } if (showOptions) { // To open the PowerToys settings in the ZoomIt page. @@ -6167,12 +6490,10 @@ LRESULT APIENTRY MainWndProc( GetCursorPos(&local_savedCursorPos); } - HBITMAP hInterimSaveBitmap; - HDC hInterimSaveDc; - HBITMAP hSaveBitmap; - HDC hSaveDc; - int copyX, copyY; - int copyWidth, copyHeight; + // Determine the user's desired save area in zoomed viewport coordinates. + // This will be the entire viewport if the user does not select a crop + // rectangle. + int copyX = 0, copyY = 0, copyWidth = width, copyHeight = height; if ( LOWORD( wParam ) == IDC_SAVE_CROP ) { @@ -6187,55 +6508,51 @@ LRESULT APIENTRY MainWndProc( } break; } + auto copyRc = selectRectangle.SelectedRect(); selectRectangle.Stop(); g_RecordCropping = FALSE; + copyX = copyRc.left; copyY = copyRc.top; copyWidth = copyRc.right - copyRc.left; copyHeight = copyRc.bottom - copyRc.top; } - else - { - copyX = 0; - copyY = 0; - copyWidth = width; - copyHeight = height; - } OutputDebug( L"***x: %d, y: %d, width: %d, height: %d\n", copyX, copyY, copyWidth, copyHeight ); RECT oldClipRect{}; GetClipCursor( &oldClipRect ); ClipCursor( NULL ); - // Capture the screen before displaying the save dialog - hInterimSaveDc = CreateCompatibleDC( hdcScreen ); - hInterimSaveBitmap = CreateCompatibleBitmap( hdcScreen, copyWidth, copyHeight ); - SelectObject( hInterimSaveDc, hInterimSaveBitmap ); + // Translate the viewport selection into coordinates for the 1:1 source + // bitmap hdcScreenCompat. + int viewportX, viewportY; + GetZoomedTopLeftCoordinates( + zoomLevel, &cursorPos, &viewportX, width, &viewportY, height ); - hSaveDc = CreateCompatibleDC( hdcScreen ); -#if SCALE_HALFTONE - SetStretchBltMode( hInterimSaveDc, HALFTONE ); - SetStretchBltMode( hSaveDc, HALFTONE ); -#else - // 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, - copyWidth, copyHeight, - hdcScreen, - monInfo.rcMonitor.left + copyX, - monInfo.rcMonitor.top + copyY, - copyWidth, copyHeight, - SRCCOPY|CAPTUREBLT ); + int saveX = viewportX + static_cast( copyX / zoomLevel ); + int saveY = viewportY + static_cast( copyY / zoomLevel ); + int saveWidth = static_cast( copyWidth / zoomLevel ); + int saveHeight = static_cast( copyHeight / zoomLevel ); + // Create a pixel-accurate copy of the desired area from the source bitmap. + wil::unique_hdc hdcActualSize( CreateCompatibleDC( hdcScreen ) ); + wil::unique_hbitmap hbmActualSize( + CreateCompatibleBitmap( hdcScreen, saveWidth, saveHeight ) ); + // Note: we do not need to restore the existing context later. The objects + // are transient and not reused. + SelectObject( hdcActualSize.get(), hbmActualSize.get() ); + + // Perform a direct 1:1 copy from the backing bitmap. + BitBlt( hdcActualSize.get(), + 0, 0, + saveWidth, saveHeight, + hdcScreenCompat, + saveX, saveY, + SRCCOPY | CAPTUREBLT ); + + // Open the Save As dialog and capture the desired file path and whether to + // save the zoomed display or the source bitmap pixels. g_bSaveInProgress = true; memset( &openFileName, 0, sizeof(openFileName )); openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; @@ -6247,10 +6564,11 @@ LRESULT APIENTRY MainWndProc( openFileName.lpstrDefExt = NULL; // "*.png"; openFileName.nFilterIndex = 1; openFileName.lpstrFilter = L"Zoomed PNG\0*.png\0" - //"Zoomed BMP\0*.bmp\0" + //"Zoomed BMP\0*.bmp\0" "Actual size PNG\0*.png\0\0"; //"Actual size BMP\0*.bmp\0\0"; openFileName.lpstrFile = filePath; + if( GetSaveFileName( &openFileName ) ) { TCHAR targetFilePath[MAX_PATH]; @@ -6260,42 +6578,47 @@ LRESULT APIENTRY MainWndProc( _tcscat( targetFilePath, L".png" ); } - // Save image at screen size - if( openFileName.nFilterIndex == 1 ) + if( openFileName.nFilterIndex == 2 ) { - SavePng( targetFilePath, hInterimSaveBitmap ); + // Save at actual size. + SavePng( targetFilePath, hbmActualSize.get() ); } - // Save image scaled down to actual size else { - int saveWidth = static_cast( copyWidth / zoomLevel ); - int saveHeight = static_cast( copyHeight / zoomLevel ); + // Save zoomed-in image at screen resolution. +#if SCALE_HALFTONE + const int bltMode = HALFTONE; +#else + // Use HALFTONE for better quality when smooth image is enabled + const int bltMode = g_SmoothImage ? HALFTONE : COLORONCOLOR; +#endif + // Recreate the zoomed-in view by upscaling from our source bitmap. + wil::unique_hdc hdcZoomed( CreateCompatibleDC(hdcScreen) ); + wil::unique_hbitmap hbmZoomed( + CreateCompatibleBitmap( hdcScreen, copyWidth, copyHeight ) ); + SelectObject( hdcZoomed.get(), hbmZoomed.get() ); - hSaveBitmap = CreateCompatibleBitmap( hdcScreen, saveWidth, saveHeight ); - SelectObject( hSaveDc, hSaveBitmap ); + SetStretchBltMode( hdcZoomed.get(), bltMode ); - StretchBlt( hSaveDc, + StretchBlt( hdcZoomed.get(), + 0, 0, + copyWidth, copyHeight, + hdcActualSize.get(), 0, 0, saveWidth, saveHeight, - hInterimSaveDc, - 0, - 0, - copyWidth, copyHeight, SRCCOPY | CAPTUREBLT ); - - SavePng( targetFilePath, hSaveBitmap ); + + SavePng( targetFilePath, hbmZoomed.get() ); } } g_bSaveInProgress = false; - DeleteDC( hInterimSaveDc ); - DeleteDC( hSaveDc ); - if( lParam != SHALLOW_ZOOM ) { - SetCursorPos(local_savedCursorPos.x, local_savedCursorPos.y); + SetCursorPos( local_savedCursorPos.x, local_savedCursorPos.y ); } ClipCursor( &oldClipRect ); + break; } @@ -6362,10 +6685,10 @@ LRESULT APIENTRY MainWndProc( monInfo.rcMonitor.left + copyX, monInfo.rcMonitor.top + copyY, copyWidth, copyHeight, - SRCCOPY|CAPTUREBLT ); + SRCCOPY|CAPTUREBLT ); if( OpenClipboard( hWnd )) { - + EmptyClipboard(); SetClipboardData( CF_BITMAP, hSaveBitmap ); CloseClipboard(); @@ -6375,7 +6698,7 @@ LRESULT APIENTRY MainWndProc( } break; - case IDC_DRAW: + case IDC_DRAW: PostMessage( hWnd, WM_HOTKEY, DRAW_HOTKEY, 1 ); break; @@ -6459,10 +6782,10 @@ LRESULT APIENTRY MainWndProc( ( !g_TimerActive || wcscmp( activeBreakBackgroundFile, g_BreakBackgroundFile ) ) ) { _tcscpy( activeBreakBackgroundFile, g_BreakBackgroundFile ); - + DeleteObject( g_hBackgroundBmp ); DeleteDC( g_hDcBackgroundFile ); - + g_hBackgroundBmp = NULL; g_hBackgroundBmp = LoadImageFile( g_BreakBackgroundFile ); if( g_hBackgroundBmp == NULL ) @@ -6504,15 +6827,15 @@ LRESULT APIENTRY MainWndProc( hNegativeTimerFont = CreateFontIndirect( &g_LogFont ); // Create backing bitmap - hdcScreenCompat = CreateCompatibleDC(hdcScreen); + hdcScreenCompat = CreateCompatibleDC(hdcScreen); bmp.bmBitsPixel = static_cast(GetDeviceCaps(hdcScreen, BITSPIXEL)); bmp.bmPlanes = static_cast(GetDeviceCaps(hdcScreen, PLANES)); bmp.bmWidth = width; bmp.bmHeight = height; - bmp.bmWidthBytes = ((bmp.bmWidth + 15) &~15)/8; - hbmpCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, - bmp.bmPlanes, bmp.bmBitsPixel, static_cast(NULL)); - SelectObject(hdcScreenCompat, hbmpCompat); + bmp.bmWidthBytes = ((bmp.bmWidth + 15) &~15)/8; + hbmpCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, + bmp.bmPlanes, bmp.bmBitsPixel, static_cast(NULL)); + SelectObject(hdcScreenCompat, hbmpCompat); SetTextColor( hdcScreenCompat, g_BreakPenColor ); SetBkMode( hdcScreenCompat, TRANSPARENT ); @@ -6527,7 +6850,7 @@ LRESULT APIENTRY MainWndProc( BringWindowToTop( hWnd ); SetForegroundWindow( hWnd ); SetActiveWindow( hWnd ); - SetWindowPos( hWnd, HWND_NOTOPMOST, monInfo.rcMonitor.left, monInfo.rcMonitor.top, + SetWindowPos( hWnd, HWND_NOTOPMOST, monInfo.rcMonitor.left, monInfo.rcMonitor.top, width, height, SWP_SHOWWINDOW ); } break; @@ -6535,10 +6858,10 @@ LRESULT APIENTRY MainWndProc( case IDCANCEL: memset( &tNotifyIconData, 0, sizeof(tNotifyIconData)); - tNotifyIconData.cbSize = sizeof(NOTIFYICONDATA); - tNotifyIconData.hWnd = hWnd; - tNotifyIconData.uID = 1; - Shell_NotifyIcon(NIM_DELETE, &tNotifyIconData); + tNotifyIconData.cbSize = sizeof(NOTIFYICONDATA); + tNotifyIconData.hWnd = hWnd; + tNotifyIconData.uID = 1; + Shell_NotifyIcon(NIM_DELETE, &tNotifyIconData); reg.WriteRegSettings( RegSettings ); if( hWndOptions ) @@ -6561,93 +6884,12 @@ LRESULT APIENTRY MainWndProc( if( breakTimeout == 0 && g_BreakPlaySoundFile ) { PlaySound( g_BreakSoundFile, NULL, SND_FILENAME|SND_ASYNC ); - } + } break; case 2: case 1: - // - // Telescoping zoom timer - // - if( zoomTelescopeStep ) { - - zoomLevel *= zoomTelescopeStep; - if( (zoomTelescopeStep > 1 && zoomLevel >= zoomTelescopeTarget ) || - (zoomTelescopeStep < 1 && zoomLevel <= zoomTelescopeTarget )) { - - zoomLevel = zoomTelescopeTarget; - KillTimer( hWnd, wParam ); - OutputDebug( L"SETCURSOR mon_left: %x mon_top: %x x: %d y: %d\n", - monInfo.rcMonitor.left, monInfo.rcMonitor.top, cursorPos.x, cursorPos.y ); - SetCursorPos( monInfo.rcMonitor.left + cursorPos.x, - monInfo.rcMonitor.top + cursorPos.y ); - } - - } else { - - // Case where we didn't zoom at all - KillTimer( hWnd, wParam ); - } - if( wParam == 2 && zoomLevel == 1 ) { - - g_Zoomed = FALSE; - if( g_ZoomOnLiveZoom ) - { - GetCursorPos( &cursorPos ); - cursorPos = ScalePointInRects( cursorPos, monInfo.rcMonitor, g_LiveZoomSourceRect ); - SetCursorPos( cursorPos.x, cursorPos.y ); - SendMessage(hWnd, WM_HOTKEY, LIVE_HOTKEY, 0); - } - else if( lParam != SHALLOW_ZOOM ) - { - // Figure out where final unzoomed cursor should be - if (g_Drawing) { - cursorPos = prevPt; - } - OutputDebug(L"FINAL MOUSE: x: %d y: %d\n", cursorPos.x, cursorPos.y ); - GetZoomedTopLeftCoordinates(zoomLevel, &cursorPos, &x, width, &y, height); - cursorPos.x = monInfo.rcMonitor.left + x + static_cast((cursorPos.x - x) * zoomLevel); - cursorPos.y = monInfo.rcMonitor.top + y + static_cast((cursorPos.y - y) * zoomLevel); - SetCursorPos(cursorPos.x, cursorPos.y); - } - if( hTargetWindow ) { - - SetWindowPos( hTargetWindow, HWND_BOTTOM, rcTargetWindow.left, rcTargetWindow.top, - rcTargetWindow.right - rcTargetWindow.left, - rcTargetWindow.bottom - rcTargetWindow.top, 0 ); - hTargetWindow = NULL; - } - DeleteDrawUndoList( &drawUndoList ); - - // Restore live zoom if we came from that mode - if( g_ZoomOnLiveZoom ) { - - SendMessage( g_hWndLiveZoom, WM_USER_SET_ZOOM, static_cast(g_LiveZoomLevel), reinterpret_cast(&g_LiveZoomSourceRect) ); - g_ZoomOnLiveZoom = FALSE; - forcePenResize = TRUE; - } - - SetForegroundWindow( g_ActiveWindow ); - ClipCursor( NULL ); - g_HaveDrawn = FALSE; - g_TypeMode = TypeModeOff; - g_HaveTyped = FALSE; - g_Drawing = FALSE; - EnableDisableStickyKeys( TRUE ); - DeleteObject( hTypingFont ); - DeleteDC( hdcScreen ); - DeleteDC( hdcScreenCompat ); - DeleteDC( hdcScreenCursorCompat ); - DeleteDC( hdcScreenSaveCompat ); - DeleteObject( hbmpCompat ); - DeleteObject( hbmpCursorCompat ); - DeleteObject( hbmpDrawingCompat ); - DeleteObject( hDrawingPen ); - - SetFocus( g_ActiveWindow ); - ShowWindow( hWnd, SW_HIDE ); - } - InvalidateRect( hWnd, NULL, FALSE ); + doTelescopingZoomTimer(); break; case 3: @@ -6670,7 +6912,7 @@ LRESULT APIENTRY MainWndProc( case WM_PAINT: - hDc = BeginPaint(hWnd, &ps); + hDc = BeginPaint(hWnd, &ps); if( ( ( g_RecordCropping == FALSE ) || ( zoomLevel == 1 ) ) && g_Zoomed ) { @@ -6680,25 +6922,25 @@ LRESULT APIENTRY MainWndProc( #if SCALE_GDIPLUS if ( zoomLevel >= zoomTelescopeTarget ) { // do a high-quality render - extern void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, + extern void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, HBITMAP bmSrc, float xSrc, float ySrc, float wSrc, float hSrc ); - ScaleImage( ps.hdc, - 0, 0, - (float)bmp.bmWidth, (float)bmp.bmHeight, - hbmpCompat, - (float)x, (float)y, - width/zoomLevel, height/zoomLevel ); + ScaleImage( ps.hdc, + 0, 0, + (float)bmp.bmWidth, (float)bmp.bmHeight, + hbmpCompat, + (float)x, (float)y, + width/zoomLevel, height/zoomLevel ); } else { // 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, - hdcScreenCompat, - x, y, + StretchBlt( ps.hdc, + 0, 0, + bmp.bmWidth, bmp.bmHeight, + hdcScreenCompat, + x, y, (int) (width/zoomLevel), (int) (height/zoomLevel), - SRCCOPY); + SRCCOPY); } #else #if SCALE_HALFTONE @@ -6711,13 +6953,13 @@ LRESULT APIENTRY MainWndProc( SetStretchBltMode( hDc, COLORONCOLOR ); } #endif - StretchBlt( ps.hdc, - 0, 0, - bmp.bmWidth, bmp.bmHeight, - hdcScreenCompat, - x, y, + StretchBlt( ps.hdc, + 0, 0, + bmp.bmWidth, bmp.bmHeight, + hdcScreenCompat, + x, y, static_cast(width/zoomLevel), static_cast(height/zoomLevel), - SRCCOPY|CAPTUREBLT ); + SRCCOPY|CAPTUREBLT ); #endif } else if( g_TimerActive ) { @@ -6737,7 +6979,7 @@ LRESULT APIENTRY MainWndProc( StretchBlt( hdcScreenCompat, 0, 0, width, height, g_hDcBackgroundFile, 0, 0, local_bmp.bmWidth, local_bmp.bmHeight, SRCCOPY|CAPTUREBLT ); } else { - BitBlt( hdcScreenCompat, width/2 - local_bmp.bmWidth/2, height/2 - local_bmp.bmHeight/2, + BitBlt( hdcScreenCompat, width/2 - local_bmp.bmWidth/2, height/2 - local_bmp.bmHeight/2, local_bmp.bmWidth, local_bmp.bmHeight, g_hDcBackgroundFile, 0, 0, SRCCOPY|CAPTUREBLT ); } } @@ -6746,13 +6988,13 @@ LRESULT APIENTRY MainWndProc( if( breakTimeout > 0 ) { _stprintf( timerText, L"% 2d:%02d", breakTimeout/60, breakTimeout % 60 ); - + } else { _tcscpy( timerText, L"0:00" ); } rc.left = rc.top = 0; - DrawText( hdcScreenCompat, timerText, -1, &rc, + DrawText( hdcScreenCompat, timerText, -1, &rc, DT_NOCLIP|DT_LEFT|DT_NOPREFIX|DT_CALCRECT ); rc1.left = rc1.right = rc1.bottom = rc1.top = 0; @@ -6761,7 +7003,7 @@ LRESULT APIENTRY MainWndProc( _stprintf( negativeTimerText, L"(-% 2d:%02d)", -breakTimeout/60, -breakTimeout % 60 ); HFONT prevFont = static_cast(SelectObject( hdcScreenCompat, hNegativeTimerFont )); - DrawText( hdcScreenCompat, negativeTimerText, -1, &rc1, + DrawText( hdcScreenCompat, negativeTimerText, -1, &rc1, DT_NOCLIP|DT_LEFT|DT_NOPREFIX|DT_CALCRECT ); SelectObject( hdcScreenCompat, prevFont ); } @@ -6813,7 +7055,7 @@ LRESULT APIENTRY MainWndProc( rc1.top = rc.bottom + 10; rc1.left = rc.left + ((rc.right - rc.left)-(rc1.right-rc1.left))/2; HFONT prevFont = static_cast(SelectObject( hdcScreenCompat, hNegativeTimerFont )); - DrawText( hdcScreenCompat, negativeTimerText, -1, &rc1, + DrawText( hdcScreenCompat, negativeTimerText, -1, &rc1, DT_NOCLIP|DT_LEFT|DT_NOPREFIX ); SelectObject( hdcScreenCompat, prevFont ); } @@ -6821,7 +7063,7 @@ LRESULT APIENTRY MainWndProc( // Copy to screen BitBlt( ps.hdc, 0, 0, width, height, hdcScreenCompat, 0, 0, SRCCOPY|CAPTUREBLT ); } - EndPaint(hWnd, &ps); + EndPaint(hWnd, &ps); return TRUE; case WM_DESTROY: @@ -6906,7 +7148,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM if( !startedInPresentationMode ) { SetTimer( hWnd, 1, LIVEZOOM_WINDOW_TIMEOUT, NULL ); - } + } } break; @@ -6934,7 +7176,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM UpdateWindow(hWnd); } - // Are we coming back from a static zoom that + // Are we coming back from a static zoom that // was started while we were live zoomed? if( g_ZoomOnLiveZoom ) { @@ -6974,7 +7216,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM SendMessage( hWnd, WM_TIMER, 0, 0); SetTimer( hWnd, 0, ZOOM_LEVEL_STEP_TIME, NULL ); - + } else { KillTimer( hWnd, 0 ); @@ -7012,14 +7254,14 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM GetCursorPos(&cursorPos); - // Reclaim topmost status, to prevent unmagnified menus from remaining in view. + // Reclaim topmost status, to prevent unmagnified menus from remaining in view. memset(&matrix, 0, sizeof(matrix)); if( !g_fullScreenWorkaround ) { pSetLayeredWindowAttributes( hWnd, 0, 255, LWA_ALPHA ); SetWindowPos(hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE); - + OutputDebug(L"LIVEZOOM RECLAIM\n"); } @@ -7028,7 +7270,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM moveWidth = sourceRectWidth/LIVEZOOM_MOVE_REGIONS; moveHeight = sourceRectHeight/LIVEZOOM_MOVE_REGIONS; curTickCount = GetTickCount(); - if( zoomLevel != zoomTelescopeTarget && + if( zoomLevel != zoomTelescopeTarget && (prevZoomStepTickCount == 0 || (curTickCount - prevZoomStepTickCount > ZOOM_LEVEL_STEP_TIME)) ) { prevZoomStepTickCount = curTickCount; @@ -7040,7 +7282,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM } else { zoomLevel *= zoomTelescopeStep; - } + } // Time to exit zoom mode? if( zoomTelescopeTarget == 1 && zoomLevel == 1 ) { @@ -7059,13 +7301,13 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM matrix.v[1][2] = (static_cast(-lastSourceRect.top) * zoomLevel ); matrix.v[2][2] = 1.0f; } - + // // Pre-adjust for monitor boundary // adjustedCursorPos.x = cursorPos.x - monInfo.rcMonitor.left; adjustedCursorPos.y = cursorPos.y - monInfo.rcMonitor.top; - GetZoomedTopLeftCoordinates( zoomLevel, &adjustedCursorPos, reinterpret_cast(&zoomCenterPos.x), width, + GetZoomedTopLeftCoordinates( zoomLevel, &adjustedCursorPos, reinterpret_cast(&zoomCenterPos.x), width, reinterpret_cast(&zoomCenterPos.y), height ); // @@ -7078,11 +7320,11 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM int xOffset = cursorPos.x - lastSourceRect.left; int yOffset = cursorPos.y - lastSourceRect.top; - zoomCenterPos.x = 0; - zoomCenterPos.y = 0; - if( xOffset < moveWidth ) + zoomCenterPos.x = 0; + zoomCenterPos.y = 0; + if( xOffset < moveWidth ) zoomCenterPos.x = lastSourceRect.left + sourceRectWidth/2 - (moveWidth - xOffset); - else if( xOffset > moveWidth * (LIVEZOOM_MOVE_REGIONS-1) ) + else if( xOffset > moveWidth * (LIVEZOOM_MOVE_REGIONS-1) ) zoomCenterPos.x = lastSourceRect.left + sourceRectWidth/2 + (xOffset - moveWidth*(LIVEZOOM_MOVE_REGIONS-1)); if( yOffset < moveHeight ) zoomCenterPos.y = lastSourceRect.top + sourceRectHeight/2 - (moveHeight - yOffset); @@ -7090,8 +7332,8 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM zoomCenterPos.y = lastSourceRect.top + sourceRectHeight/2 + (yOffset - moveHeight*(LIVEZOOM_MOVE_REGIONS-1)); } if( matrix.v[0][0] || zoomCenterPos.x || zoomCenterPos.y ) { - - if( zoomCenterPos.y == 0 ) + + if( zoomCenterPos.y == 0 ) zoomCenterPos.y = lastSourceRect.top + sourceRectHeight/2; if( zoomCenterPos.x == 0 ) zoomCenterPos.x = lastSourceRect.left + sourceRectWidth/2; @@ -7102,14 +7344,14 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM sourceRect.top = zoomCenterPos.y - zoomHeight / 2; // Don't scroll outside desktop area. - if (sourceRect.left < monInfo.rcMonitor.left) + if (sourceRect.left < monInfo.rcMonitor.left) sourceRect.left = monInfo.rcMonitor.left; else if (sourceRect.left > monInfo.rcMonitor.right - zoomWidth ) sourceRect.left = monInfo.rcMonitor.right - zoomWidth; sourceRect.right = sourceRect.left + zoomWidth; - if (sourceRect.top < monInfo.rcMonitor.top) + if (sourceRect.top < monInfo.rcMonitor.top) sourceRect.top = monInfo.rcMonitor.top; - else if (sourceRect.top > monInfo.rcMonitor.bottom - zoomHeight) + else if (sourceRect.top > monInfo.rcMonitor.bottom - zoomHeight) sourceRect.top = monInfo.rcMonitor.bottom - zoomHeight; sourceRect.bottom = sourceRect.top + zoomHeight; @@ -7194,7 +7436,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM DestroyWindow( hWnd ); } - } + } } break; } @@ -7207,9 +7449,9 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM // Existing presentation mode DestroyWindow( hWnd ); - + } else if( !startedInPresentationMode && IsPresentationMode()) { - + // Kill the timer if one was configured, because now // we're going to go away when they exit presentation mode KillTimer( hWnd, 1 ); @@ -7221,19 +7463,19 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM float newZoomLevel = zoomLevel; switch( wParam ) { case 0: - // zoom in - if( newZoomLevel < ZOOM_LEVEL_MAX ) + // zoom in + if( newZoomLevel < ZOOM_LEVEL_MAX ) newZoomLevel *= 2; zoomTelescopeStep = ZOOM_LEVEL_STEP_IN; break; case 1: - if( newZoomLevel > 2 ) + if( newZoomLevel > 2 ) newZoomLevel /= 2; else { - newZoomLevel *= .75; - if( newZoomLevel < ZOOM_LEVEL_MIN ) + newZoomLevel *= .75; + if( newZoomLevel < ZOOM_LEVEL_MIN ) newZoomLevel = ZOOM_LEVEL_MIN; } zoomTelescopeStep = ZOOM_LEVEL_STEP_OUT; @@ -7260,12 +7502,12 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM break; case VK_UP: - SendMessage( hWnd, WM_MOUSEWHEEL, + SendMessage( hWnd, WM_MOUSEWHEEL, MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 ? MK_CONTROL: 0, WHEEL_DELTA), 0 ); return TRUE; case VK_DOWN: - SendMessage( hWnd, WM_MOUSEWHEEL, + SendMessage( hWnd, WM_MOUSEWHEEL, MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 ? MK_CONTROL: 0, -WHEEL_DELTA), 0 ); return TRUE; } @@ -7273,10 +7515,10 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM case WM_DESTROY: g_hWndLiveZoom = NULL; break; - + case WM_SIZE: GetClientRect(hWnd, &rc); - SetWindowPos(g_hWndLiveZoomMag, NULL, + SetWindowPos(g_hWndLiveZoomMag, NULL, rc.left, rc.top, rc.right, rc.bottom, 0 ); break; @@ -7352,7 +7594,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM default: return DefWindowProc(hWnd, message, wParam, lParam); } - return 0; + return 0; } @@ -7407,7 +7649,7 @@ HRESULT __stdcall WrapD3D11CreateDevice( // InitInstance // //---------------------------------------------------------------------------- -HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) +HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) { WNDCLASS wcZoomIt; HWND hWndMain; @@ -7423,7 +7665,7 @@ HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) wcZoomIt.cbClsExtra = 0; wcZoomIt.cbWndExtra = 0; wcZoomIt.hInstance = hInstance; - wcZoomIt.hIcon = 0; + wcZoomIt.hIcon = 0; wcZoomIt.hCursor = LoadCursor(NULL, IDC_ARROW); wcZoomIt.hbrBackground = NULL; wcZoomIt.lpszMenuName = NULL; @@ -7435,40 +7677,40 @@ HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) g_LiveZoomToggleKey = 0; } - wcZoomIt.style = 0; - wcZoomIt.lpfnWndProc = (WNDPROC)MainWndProc; - wcZoomIt.cbClsExtra = 0; - wcZoomIt.cbWndExtra = 0; + wcZoomIt.style = 0; + wcZoomIt.lpfnWndProc = (WNDPROC)MainWndProc; + wcZoomIt.cbClsExtra = 0; + wcZoomIt.cbWndExtra = 0; wcZoomIt.hInstance = hInstance; wcZoomIt.hIcon = NULL; wcZoomIt.hCursor = LoadCursor( hInstance, L"NULLCURSOR" ); wcZoomIt.hbrBackground = NULL; - wcZoomIt.lpszMenuName = NULL; + wcZoomIt.lpszMenuName = NULL; wcZoomIt.lpszClassName = L"ZoomitClass"; if ( ! RegisterClass(&wcZoomIt) ) return FALSE; - hWndMain = CreateWindowEx( WS_EX_TOOLWINDOW, L"ZoomitClass", - L"Zoomit Zoom Window", + hWndMain = CreateWindowEx( WS_EX_TOOLWINDOW, L"ZoomitClass", + L"Zoomit Zoom Window", WS_POPUP, - 0, 0, 0, 0, - NULL, - NULL, - hInstance, + 0, 0, + NULL, + NULL, + hInstance, NULL); - // If window could not be created, return "failure" + // If window could not be created, return "failure" if (!hWndMain ) return NULL; - // Make the window visible; update its client area; and return "success" + // Make the window visible; update its client area; and return "success" ShowWindow(hWndMain, SW_HIDE); // Add tray icon EnableDisableTrayIcon( hWndMain, g_ShowTrayIcon ); - return hWndMain; + return hWndMain; -} +} //---------------------------------------------------------------------------- // @@ -7478,7 +7720,7 @@ HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ PWSTR lpCmdLine, _In_ int nCmdShow ) { - MSG msg; + MSG msg; HACCEL hAccel; if( !ShowEula( APPNAME, NULL, NULL )) return 1; @@ -7541,7 +7783,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance if( !CreateEvent( NULL, FALSE, FALSE, _T("Local\\ZoomitActive"))) { CreateEvent( NULL, FALSE, FALSE, _T("ZoomitActive")); - } + } if( GetLastError() == ERROR_ALREADY_EXISTS ) { if (g_StartedByPowerToys) { @@ -7562,7 +7804,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance if( local_hWndOptions ) { SetForegroundWindow( local_hWndOptions ); - SetWindowPos( local_hWndOptions, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW ); + SetWindowPos( local_hWndOptions, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW ); break; } Sleep( 100 ); diff --git a/src/modules/ZoomIt/ZoomIt/resource.h b/src/modules/ZoomIt/ZoomIt/resource.h index 568eebda4b..2458e8ce75 100644 --- a/src/modules/ZoomIt/ZoomIt/resource.h +++ b/src/modules/ZoomIt/ZoomIt/resource.h @@ -93,6 +93,7 @@ #define IDC_DEMOTYPE_SLIDER2 1074 #define IDC_DEMOTYPE_STATIC2 1074 #define IDC_COPYRIGHT 1075 +#define IDC_RECORD_FORMAT 1076 #define IDC_PEN_WIDTH 1105 #define IDC_TIMER 1106 #define IDC_SMOOTH_IMAGE 1107 diff --git a/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettings.cpp b/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettings.cpp index 3eb3e235da..25d9678ef2 100644 --- a/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettings.cpp +++ b/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettings.cpp @@ -14,6 +14,9 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation const unsigned int SPECIAL_SEMANTICS_SHORTCUT = 1; const unsigned int SPECIAL_SEMANTICS_COLOR = 2; const unsigned int SPECIAL_SEMANTICS_LOG_FONT = 3; + const unsigned int SPECIAL_SEMANTICS_RECORDING_FORMAT = 4; + const unsigned int SPECIAL_SEMANTICS_RECORD_SCALING_GIF = 5; + const unsigned int SPECIAL_SEMANTICS_RECORD_SCALING_MP4 = 6; std::vector base64_decode(const std::wstring& base64_string) { @@ -72,6 +75,9 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation { L"PenColor", SPECIAL_SEMANTICS_COLOR }, { L"BreakPenColor", SPECIAL_SEMANTICS_COLOR }, { L"Font", SPECIAL_SEMANTICS_LOG_FONT }, + { L"RecordingFormat", SPECIAL_SEMANTICS_RECORDING_FORMAT }, + { L"RecordScalingGIF", SPECIAL_SEMANTICS_RECORD_SCALING_GIF }, + { L"RecordScalingMP4", SPECIAL_SEMANTICS_RECORD_SCALING_MP4 }, }; hstring ZoomItSettings::LoadSettingsJson() @@ -103,6 +109,11 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation value & 0xFF); _settings.add_property(curSetting->ValueName, hotkey.get_json()); } + else if (special_semantics->second == SPECIAL_SEMANTICS_RECORDING_FORMAT) + { + std::wstring formatString = (value == 0) ? L"GIF" : L"MP4"; + _settings.add_property(L"RecordFormat", formatString); + } else if (special_semantics->second == SPECIAL_SEMANTICS_COLOR) { /* PowerToys settings likes colors as #FFFFFF strings. @@ -156,6 +167,9 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation curSetting++; } + DWORD recordScaling = (g_RecordingFormat == static_cast(0)) ? g_RecordScalingGIF : g_RecordScalingMP4; + _settings.add_property(L"RecordScaling", recordScaling); + return _settings.get_raw_json().Stringify(); } @@ -167,6 +181,8 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation PowerToysSettings::PowerToyValues valuesFromSettings = PowerToysSettings::PowerToyValues::from_json_string(json, L"ZoomIt"); + bool formatChanged = false; + PREG_SETTING curSetting = RegSettings; while (curSetting->ValueName) { @@ -212,6 +228,42 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation *static_cast(curSetting->Setting) = value; } } + else if (special_semantics->second == SPECIAL_SEMANTICS_RECORDING_FORMAT) + { + // Convert string ("GIF" or "MP4") to DWORD enum value (0=GIF, 1=MP4) + auto possibleValue = valuesFromSettings.get_string_value(L"RecordFormat"); + if (possibleValue.has_value()) + { + RecordingFormat oldFormat = g_RecordingFormat; + DWORD formatValue = (possibleValue.value() == L"GIF") ? 0 : 1; + RecordingFormat newFormat = static_cast(formatValue); + + *static_cast(curSetting->Setting) = formatValue; + + if (oldFormat != newFormat) + { + formatChanged = true; + + if (oldFormat == static_cast(0)) + { + g_RecordScalingGIF = g_RecordScaling; + } + else + { + g_RecordScalingMP4 = g_RecordScaling; + } + + if (newFormat == static_cast(0)) + { + g_RecordScaling = g_RecordScalingGIF; + } + else + { + g_RecordScaling = g_RecordScalingMP4; + } + } + } + } else if (special_semantics->second == SPECIAL_SEMANTICS_COLOR) { /* PowerToys settings likes colors as #FFFFFF strings. @@ -275,6 +327,22 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation } curSetting++; } + + auto recordScalingValue = valuesFromSettings.get_uint_value(L"RecordScaling"); + if (recordScalingValue.has_value() && !formatChanged) + { + g_RecordScaling = recordScalingValue.value(); + + if (g_RecordingFormat == static_cast(0)) + { + g_RecordScalingGIF = recordScalingValue.value(); + } + else + { + g_RecordScalingMP4 = recordScalingValue.value(); + } + } + reg.WriteRegSettings(RegSettings); } } diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj index 98a46f3764..1daebefccb 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj @@ -29,6 +29,7 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true + MultiThreadedDebug true @@ -39,6 +40,7 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false + MultiThreaded true true diff --git a/src/modules/awake/Awake/Core/Manager.cs b/src/modules/awake/Awake/Core/Manager.cs index ad4c417b31..c6aa1c2efb 100644 --- a/src/modules/awake/Awake/Core/Manager.cs +++ b/src/modules/awake/Awake/Core/Manager.cs @@ -350,7 +350,7 @@ namespace Awake.Core TrayHelper.TimedIcon, TrayIconAction.Update); }, - _ => HandleTimerCompletion("timed"), + () => HandleTimerCompletion("timed"), _tokenSource.Token); } diff --git a/src/modules/awake/Awake/Core/Native/Bridge.cs b/src/modules/awake/Awake/Core/Native/Bridge.cs index 44812a4ef7..e82b698a47 100644 --- a/src/modules/awake/Awake/Core/Native/Bridge.cs +++ b/src/modules/awake/Awake/Core/Native/Bridge.cs @@ -88,9 +88,6 @@ namespace Awake.Core.Native [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool GetCursorPos(out Point lpPoint); - [DllImport("user32.dll", SetLastError = true)] - internal static extern bool ScreenToClient(IntPtr hWnd, ref Point lpPoint); - [DllImport("user32.dll", SetLastError = true)] internal static extern bool GetMessage(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); diff --git a/src/modules/awake/Awake/Core/TrayHelper.cs b/src/modules/awake/Awake/Core/TrayHelper.cs index 37e2de8e48..16cd5f5795 100644 --- a/src/modules/awake/Awake/Core/TrayHelper.cs +++ b/src/modules/awake/Awake/Core/TrayHelper.cs @@ -61,9 +61,8 @@ namespace Awake.Core Bridge.SetForegroundWindow(hWnd); - // Get cursor position and convert it to client coordinates + // Get cursor position in screen coordinates Bridge.GetCursorPos(out Models.Point cursorPos); - Bridge.ScreenToClient(hWnd, ref cursorPos); // Set menu information MenuInfo menuInfo = new() diff --git a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj index ae18117820..9bce019bfd 100644 --- a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj +++ b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj @@ -146,4 +146,43 @@ + + + + + + + MultiThreadedDebug + + + + %(IgnoreSpecificDefaultLibraries);libucrtd.lib + %(AdditionalOptions) /defaultlib:ucrtd.lib + + + + + + MultiThreaded + + + + %(IgnoreSpecificDefaultLibraries);libucrt.lib + %(AdditionalOptions) /defaultlib:ucrt.lib + + + + diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index 62434a632a..2a82f80a02 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -64,6 +64,8 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public string Title { get => string.IsNullOrEmpty(field) ? Name : field; protected set; } = string.Empty; + public string Id { get; protected set; } = string.Empty; + // This property maps to `IPage.IsLoading`, but we want to expose our own // `IsLoading` property as a combo of this value and `IsInitialized` public bool ModelIsLoading { get; protected set; } = true; @@ -142,6 +144,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext return; // throw? } + Id = page.Id; Name = page.Name; ModelIsLoading = page.IsLoading; Title = page.Title; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs index 3a3a9bd405..55581e65f3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs @@ -11,7 +11,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; /// /// Built-in Provider for a top-level command which can quit the application. Invokes the , which sends a . /// -public partial class BuiltInsCommandProvider : CommandProvider +public sealed partial class BuiltInsCommandProvider : CommandProvider { private readonly OpenSettingsCommand openSettings = new(); private readonly QuitCommand quitCommand = new(); @@ -21,7 +21,7 @@ public partial class BuiltInsCommandProvider : CommandProvider public override ICommandItem[] TopLevelCommands() => [ - new CommandItem(openSettings) { Subtitle = Properties.Resources.builtin_open_settings_subtitle }, + new CommandItem(openSettings) { }, new CommandItem(_newExtension) { Title = _newExtension.Title, Subtitle = Properties.Resources.builtin_new_extension_subtitle }, ]; @@ -34,7 +34,7 @@ public partial class BuiltInsCommandProvider : CommandProvider public BuiltInsCommandProvider() { - Id = "Core"; + Id = "com.microsoft.cmdpal.builtin.core"; DisplayName = Properties.Resources.builtin_display_name; Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png"); } 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 ef7c879d36..996475d559 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -30,7 +30,12 @@ public partial class MainListPage : DynamicListPage, { private readonly string[] _specialFallbacks = [ "com.microsoft.cmdpal.builtin.run", - "com.microsoft.cmdpal.builtin.calculator" + "com.microsoft.cmdpal.builtin.calculator", + "com.microsoft.cmdpal.builtin.system", + "com.microsoft.cmdpal.builtin.core", + "com.microsoft.cmdpal.builtin.websearch", + "com.microsoft.cmdpal.builtin.windowssettings", + "com.microsoft.cmdpal.builtin.datetime", ]; private readonly IServiceProvider _serviceProvider; 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 f0f68b8582..9a658e38f1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx @@ -117,11 +117,8 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - Open Command Palette settings - - Creates a project for a new Command Palette extension + Generate a new Command Palette extension project Exit Command Palette @@ -157,10 +154,10 @@ Open - Create a new extension + Create extension - Open Settings + Open Command Palette settings Successfully created your new extension! diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs index db413491db..7963aec154 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs @@ -2,21 +2,52 @@ // The 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 Windows.Graphics; namespace Microsoft.CmdPal.UI.ViewModels; public sealed class WindowPosition { + /// + /// Gets or sets left position in device pixels. + /// public int X { get; set; } + /// + /// Gets or sets top position in device pixels. + /// public int Y { get; set; } + /// + /// Gets or sets width in device pixels. + /// public int Width { get; set; } + /// + /// Gets or sets height in device pixels. + /// public int Height { get; set; } + + /// + /// Gets or sets width of the screen in device pixels where the window is located. + /// + public int ScreenWidth { get; set; } + + /// + /// Gets or sets height of the screen in device pixels where the window is located. + /// + public int ScreenHeight { get; set; } + + /// + /// Gets or sets DPI (dots per inch) of the display where the window is located. + /// + public int Dpi { get; set; } + + /// + /// Converts the window position properties to a structure representing the physical window rectangle. + /// + public RectInt32 ToPhysicalWindowRectangle() + { + return new RectInt32(X, Y, Width, Height); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs index 040dd146d0..ac941b7724 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs @@ -15,9 +15,12 @@ public class OpenPage : EventBase, IEvent { public int PageDepth { get; set; } - public OpenPage(int pageDepth) + public string Id { get; set; } + + public OpenPage(int pageDepth, string id) { PageDepth = pageDepth; + Id = id; EventName = "CmdPal_OpenPage"; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs index 0b55b03615..9b5ac21364 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs @@ -51,10 +51,10 @@ internal sealed partial class GlobalErrorHandler // without its exception being observed. It is NOT raised immediately // when the Task faults; timing depends on GC finalization. e.SetObserved(); - HandleException(e.Exception, Context.UnobservedTaskException, isRecoverable: true); + HandleException(e.Exception, Context.UnobservedTaskException); } - private void HandleException(Exception ex, Context context, bool isRecoverable = false) + private static void HandleException(Exception ex, Context context) { Logger.LogError($"Unhandled exception detected ({context})", ex); @@ -70,10 +70,25 @@ internal sealed partial class GlobalErrorHandler StoreReport(report, storeOnDesktop: false); + string message; + string caption; + try + { + message = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Message"); + caption = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Caption"); + } + catch + { + // The resource loader may not be available if the exception occurred during startup. + // Fall back to hardcoded strings in that case. + message = "Command Palette has encountered a fatal error and must close."; + caption = "Command Palette - Fatal error"; + } + PInvoke.MessageBox( HWND.Null, - "Command Palette has encountered a fatal error and must close.\n\nAn error report has been saved to your desktop.", - "Unhandled Error", + message, + caption, MESSAGEBOX_STYLE.MB_ICONERROR); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index 6ec96dddfe..b80ea69b86 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -18,6 +18,7 @@ using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; +using Microsoft.UI; using Microsoft.UI.Composition; using Microsoft.UI.Composition.SystemBackdrops; using Microsoft.UI.Input; @@ -33,6 +34,8 @@ using Windows.UI.WindowManagement; using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.Graphics.Dwm; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.HiDpi; using Windows.Win32.UI.Input.KeyboardAndMouse; using Windows.Win32.UI.WindowsAndMessaging; using WinRT; @@ -48,6 +51,9 @@ public sealed partial class MainWindow : WindowEx, IRecipient, IDisposable { + private const int DefaultWidth = 800; + private const int DefaultHeight = 480; + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")] [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")] private readonly uint WM_TASKBAR_RESTART; @@ -68,7 +74,6 @@ public sealed partial class MainWindow : WindowEx, public MainWindow() { InitializeComponent(); - HideWindow(); _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); @@ -174,22 +179,8 @@ public sealed partial class MainWindow : WindowEx, return; } - AppWindow.Resize(new SizeInt32 { Width = savedPosition.Width, Height = savedPosition.Height }); - - var savedRect = new RectInt32(savedPosition.X, savedPosition.Y, savedPosition.Width, savedPosition.Height); - var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest); - var workArea = displayArea.WorkArea; - - var maxX = workArea.X + Math.Max(0, workArea.Width - savedPosition.Width); - var maxY = workArea.Y + Math.Max(0, workArea.Height - savedPosition.Height); - - var targetPoint = new PointInt32 - { - X = Math.Clamp(savedPosition.X, workArea.X, maxX), - Y = Math.Clamp(savedPosition.Y, workArea.Y, maxY), - }; - - AppWindow.Move(targetPoint); + var newRect = EnsureWindowIsVisible(savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi); + AppWindow.MoveAndResize(newRect); } private void PositionCentered(DisplayArea displayArea) @@ -208,12 +199,16 @@ public sealed partial class MainWindow : WindowEx, private void UpdateWindowPositionInMemory() { + var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary; _currentWindowPosition = new WindowPosition { X = AppWindow.Position.X, Y = AppWindow.Position.Y, Width = AppWindow.Size.Width, Height = AppWindow.Size.Height, + Dpi = (int)this.GetDpiForWindow(), + ScreenWidth = displayArea.WorkArea.Width, + ScreenHeight = displayArea.WorkArea.Height, }; } @@ -301,8 +296,8 @@ public sealed partial class MainWindow : WindowEx, if (target == MonitorBehavior.ToLast) { - AppWindow.Resize(new SizeInt32 { Width = _currentWindowPosition.Width, Height = _currentWindowPosition.Height }); - AppWindow.Move(new PointInt32 { X = _currentWindowPosition.X, Y = _currentWindowPosition.Y }); + var newRect = EnsureWindowIsVisible(_currentWindowPosition.ToPhysicalWindowRectangle(), new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight), _currentWindowPosition.Dpi); + AppWindow.MoveAndResize(newRect); } else { @@ -331,6 +326,114 @@ public sealed partial class MainWindow : WindowEx, PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE); } + /// + /// Ensures that the window rectangle is visible on-screen. + /// + /// The window rectangle in physical pixels. + /// The desktop area the window was positioned on. + /// The window's original DPI. + /// + /// A window rectangle in physical pixels, moved to the nearest display and resized + /// if the DPI has changed. + /// + private static RectInt32 EnsureWindowIsVisible(RectInt32 windowRect, SizeInt32 originalScreen, int originalDpi) + { + var displayArea = DisplayArea.GetFromRect(windowRect, DisplayAreaFallback.Nearest); + if (displayArea is null) + { + return windowRect; + } + + var workArea = displayArea.WorkArea; + if (workArea.Width <= 0 || workArea.Height <= 0) + { + // Fallback, nothing reasonable to do + return windowRect; + } + + var effectiveDpi = GetEffectiveDpiFromDisplayId(displayArea); + if (originalDpi <= 0) + { + originalDpi = effectiveDpi; // use current DPI as baseline (no scaling adjustment needed) + } + + var hasInvalidSize = windowRect.Width <= 0 || windowRect.Height <= 0; + if (hasInvalidSize) + { + windowRect = new RectInt32(windowRect.X, windowRect.Y, DefaultWidth, DefaultHeight); + } + + // If we have a DPI change, scale the window rectangle accordingly + if (effectiveDpi != originalDpi) + { + var scalingFactor = effectiveDpi / (double)originalDpi; + windowRect = new RectInt32( + (int)Math.Round(windowRect.X * scalingFactor), + (int)Math.Round(windowRect.Y * scalingFactor), + (int)Math.Round(windowRect.Width * scalingFactor), + (int)Math.Round(windowRect.Height * scalingFactor)); + } + + var targetWidth = Math.Min(windowRect.Width, workArea.Width); + var targetHeight = Math.Min(windowRect.Height, workArea.Height); + + // Ensure at least some minimum visible area (e.g., 100 pixels) + // This helps prevent the window from being entirely offscreen, regardless of display scaling. + const int minimumVisibleSize = 100; + var isOffscreen = + windowRect.X + minimumVisibleSize > workArea.X + workArea.Width || + windowRect.X + windowRect.Width - minimumVisibleSize < workArea.X || + windowRect.Y + minimumVisibleSize > workArea.Y + workArea.Height || + windowRect.Y + windowRect.Height - minimumVisibleSize < workArea.Y; + + // if the work area size has changed, re-center the window + var workAreaSizeChanged = + originalScreen.Width != workArea.Width || + originalScreen.Height != workArea.Height; + + int targetX; + int targetY; + var recenter = isOffscreen || workAreaSizeChanged || hasInvalidSize; + if (recenter) + { + targetX = workArea.X + ((workArea.Width - targetWidth) / 2); + targetY = workArea.Y + ((workArea.Height - targetHeight) / 2); + } + else + { + targetX = windowRect.X; + targetY = windowRect.Y; + } + + return new RectInt32(targetX, targetY, targetWidth, targetHeight); + } + + private static int GetEffectiveDpiFromDisplayId(DisplayArea displayArea) + { + var effectiveDpi = 96; + + var hMonitor = (HMONITOR)Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId); + if (!hMonitor.IsNull) + { + var hr = PInvoke.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _); + if (hr == 0) + { + effectiveDpi = (int)dpiX; + } + else + { + Logger.LogWarning($"GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {displayArea.DisplayId}"); + } + } + + if (effectiveDpi <= 0) + { + effectiveDpi = 96; + } + + return effectiveDpi; + } + private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target) { // Leaving a note here, in case we ever need it: @@ -480,6 +583,9 @@ public sealed partial class MainWindow : WindowEx, Y = _currentWindowPosition.Y, Width = _currentWindowPosition.Width, Height = _currentWindowPosition.Height, + Dpi = _currentWindowPosition.Dpi, + ScreenWidth = _currentWindowPosition.ScreenWidth, + ScreenHeight = _currentWindowPosition.ScreenHeight, }; SettingsModel.SaveSettings(settings); 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 d009c626e0..e51597d268 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -21,7 +21,6 @@ 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; @@ -160,7 +159,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, new AsyncNavigationRequest(message.Page, message.CancellationToken), message.WithAnimation ? DefaultPageAnimation : _noAnimation); - PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth)); + PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth, message.Page.Id)); if (!ViewModel.IsNested) { @@ -655,15 +654,15 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, e.Handled = true; break; default: - { - // The CommandBar is responsible for handling all the item keybindings, - // since the bound context item may need to then show another - // context menu - TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); - WeakReferenceMessenger.Default.Send(msg); - e.Handled = msg.Handled; - break; - } + { + // The CommandBar is responsible for handling all the item keybindings, + // since the bound context item may need to then show another + // context menu + TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + WeakReferenceMessenger.Default.Send(msg); + e.Handled = msg.Handled; + break; + } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json index febacfc92e..4631e9aeaf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json @@ -5,6 +5,11 @@ "nativeDebugging": false, "doNotLaunchApp": false }, + "Microsoft.CmdPal.UI (Package) + Native debugging": { + "commandName": "MsixPackage", + "nativeDebugging": true, + "doNotLaunchApp": false + }, "Microsoft.CmdPal.UI (Unpackaged)": { "commandName": "Project" } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index 13cda5925b..9c66be3773 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -429,10 +429,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut.More - Last Position + Last position Reopen the window where it was last closed - - + + Settings @@ -493,28 +493,34 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Reloading extensions.. - + Discover more extensions - + Find more extensions on the Microsoft Store or WinGet. - + Learn how to create your own extensions - + Find extensions on the Microsoft Store - + Microsoft Store - + Find extensions on WinGet - + Microsoft Store - + Search extensions + + Command Palette has encountered a fatal error and must close. + + + Command Palette - Fatal error + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj index 347b5a1bc6..6e474cf5f3 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj @@ -74,11 +74,35 @@ stdcpp20 + + + MultiThreadedDebug + + + + %(IgnoreSpecificDefaultLibraries);libucrtd.lib + %(AdditionalOptions) /defaultlib:ucrtd.lib /profile /opt:ref /opt:icf + stdcpp20 + + + MultiThreaded + + + + %(IgnoreSpecificDefaultLibraries);libucrt.lib + %(AdditionalOptions) /defaultlib:ucrt.lib /profile /opt:ref /opt:icf + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs index 5b11ba6e05..af17ad8ec3 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs @@ -25,7 +25,7 @@ public class QueryTests : CommandPaletteUnitTestBase [DataRow("hibernate", "Hibernate")] [DataRow("open recycle", "Open Recycle Bin")] [DataRow("empty recycle", "Empty Recycle Bin")] - [DataRow("uefi", "UEFI Firmware Settings")] + [DataRow("uefi", "UEFI firmware settings")] public void TopLevelPageQueryTest(string input, string matchedTitle) { var settings = new Settings(); @@ -143,6 +143,6 @@ public class QueryTests : CommandPaletteUnitTestBase Assert.IsNotNull(result); var firstItem = result.FirstOrDefault(); var firstItemIsUefiCommand = firstItem?.Title.Contains("UEFI", StringComparison.OrdinalIgnoreCase) ?? false; - Assert.AreEqual(hasCommand, firstItemIsUefiCommand, $"Expected to match (or not match) 'UEFI Firmware Settings' but got '{firstItem?.Title}'"); + Assert.AreEqual(hasCommand, firstItemIsUefiCommand, $"Expected to match (or not match) 'UEFI firmware settings' but got '{firstItem?.Title}'"); } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs index 7553ca8321..00f1af30a8 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs @@ -41,7 +41,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests // Assert Assert.IsNotNull(provider); Assert.IsNotNull(provider.DisplayName); - Assert.AreEqual("DateTime", provider.Id); + Assert.AreEqual("com.microsoft.cmdpal.builtin.datetime", provider.Id); Assert.IsNotNull(provider.Icon); Assert.IsNotNull(provider.Settings); } @@ -103,7 +103,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests // Assert Assert.IsFalse(string.IsNullOrEmpty(subtitle)); - Assert.IsTrue(subtitle.Contains("Provides time and date values in different formats")); + Assert.IsTrue(subtitle.Contains("Show time and date values in different formats")); } } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs index c141d28d6e..ef8b56a1b8 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs @@ -16,7 +16,7 @@ public class WebSearchCommandProviderTests var provider = new WebSearchCommandsProvider(); // Assert - Assert.AreEqual("WebSearch", provider.Id); + Assert.AreEqual("com.microsoft.cmdpal.builtin.websearch", provider.Id); } [TestMethod] diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs index 78cd82062a..7fe4e5281d 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs @@ -49,8 +49,8 @@ public class BasicTests : CommandPaletteTestBase { SetSearchBox("time and date"); - var searchFileItem = this.Find("Time and Date"); - Assert.AreEqual(searchFileItem.Name, "Time and Date"); + var searchFileItem = this.Find("Time and date"); + Assert.AreEqual(searchFileItem.Name, "Time and date"); searchFileItem.DoubleClick(); SetTimeAndDaterExtensionSearchBox("year"); @@ -63,8 +63,8 @@ public class BasicTests : CommandPaletteTestBase { SetSearchBox("Windows Terminal"); - var searchFileItem = this.Find("Open Windows Terminal Profiles"); - Assert.AreEqual(searchFileItem.Name, "Open Windows Terminal Profiles"); + var searchFileItem = this.Find("Open Windows Terminal profiles"); + Assert.AreEqual(searchFileItem.Name, "Open Windows Terminal profiles"); searchFileItem.DoubleClick(); // SetSearchBox("PowerShell"); @@ -74,10 +74,10 @@ public class BasicTests : CommandPaletteTestBase [TestMethod] public void BasicWindowsSettingsTest() { - SetSearchBox("Windows Settings"); + SetSearchBox("Windows settings"); - var searchFileItem = this.Find("Windows Settings"); - Assert.AreEqual(searchFileItem.Name, "Windows Settings"); + var searchFileItem = this.Find("Windows settings"); + Assert.AreEqual(searchFileItem.Name, "Windows settings"); searchFileItem.DoubleClick(); SetSearchBox("power"); diff --git a/src/modules/cmdpal/custom.props b/src/modules/cmdpal/custom.props index 86541e31cc..2cfa0bbf4b 100644 --- a/src/modules/cmdpal/custom.props +++ b/src/modules/cmdpal/custom.props @@ -5,7 +5,7 @@ true 2025 0 - 6 + 7 Microsoft Command Palette diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs index b5ce5522bb..7232e955d7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs @@ -35,7 +35,6 @@ public partial class AllAppsCommandProvider : CommandProvider _listItem = new(_page) { - Subtitle = Resources.search_installed_apps, MoreCommands = [new CommandContextItem(AllAppsSettings.Instance.Settings.SettingsPage)], }; 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 d97bde7037..e99ffae352 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs @@ -15,8 +15,6 @@ namespace Microsoft.CmdPal.Ext.Apps.Programs; public sealed partial class AppListItem : ListItem { - private static readonly Tag _appTag = new("App"); - private readonly AppCommand _appCommand; private readonly AppItem _app; private readonly Lazy
_details; @@ -48,7 +46,6 @@ public sealed partial class AppListItem : ListItem _app = app; Title = app.Name; Subtitle = app.Subtitle; - Tags = [_appTag]; Icon = Icons.GenericAppIcon; MoreCommands = AddPinCommands(_app.Commands!, isPinned); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs index 15cc4d338e..9fdf833b0a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [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 { @@ -61,7 +61,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// - /// Looks up a localized string similar to All Apps. + /// Looks up a localized string similar to Search apps. /// internal static string all_apps { get { @@ -313,16 +313,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// - /// Looks up a localized string similar to Search installed apps. - /// - internal static string search_installed_apps { - get { - return ResourceManager.GetString("search_installed_apps", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Search installed apps.... + /// Looks up a localized string similar to Search apps.... /// internal static string search_installed_apps_placeholder { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx index dae13838e6..758c61e20f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx @@ -124,14 +124,11 @@ Installed apps - - Search installed apps - - All Apps + Search apps - Search installed apps... + Search apps... Open path in console diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs index 2b408f24dc..5e044247ba 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs @@ -120,7 +120,6 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System var indexerPage = new IndexerPage(query, _searchEngine, _queryCookie, results); Title = string.Format(CultureInfo.CurrentCulture, fallbackItemSearchPageTitleCompositeFormat, query); Icon = Icons.FileExplorerIcon; - Subtitle = Resources.Indexer_Subtitle; Command = indexerPage; return; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs index d2ea9b9b0c..ca1f215d4c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs @@ -33,7 +33,6 @@ public partial class IndexerCommandsProvider : CommandProvider new CommandItem(new IndexerPage()) { Title = Resources.Indexer_Title, - Subtitle = Resources.Indexer_Subtitle, } ]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs index 0ed9458398..41abc0b018 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs @@ -68,7 +68,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable _noSearchEmptyContent = new CommandItem(new NoOpCommand()) { Icon = Icon, - Title = Resources.Indexer_Subtitle, Subtitle = Resources.Indexer_NoSearchQueryMessageTip, }; 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 d4d8f9cd0f..4394cd6697 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs @@ -295,15 +295,6 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } } - /// - /// Looks up a localized string similar to Search files on this device. - /// - internal static string Indexer_Subtitle { - get { - return ResourceManager.GetString("Indexer_Subtitle", resourceCulture); - } - } - /// /// Looks up a localized string similar to Search files. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx index 8e67eae4e9..9ea1c4563e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx @@ -171,9 +171,6 @@ Only when file path exist - - Search files on this device - Search files diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs index 22eca4cc3f..5d68f013a4 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs @@ -25,8 +25,7 @@ public partial class RegistryCommandsProvider : CommandProvider return [ new CommandItem(new RegistryListPage(_settingsManager)) { - Title = "Registry", - Subtitle = "Navigate the Windows registry", + Title = "Browse the Windows registry", } ]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs index 4200c3050a..e5949f8a02 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs @@ -97,7 +97,7 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties { } /// - /// Looks up a localized string similar to Executes commands (e.g. 'ping', 'cmd'). + /// Looks up a localized string similar to Execute system commands like 'ping' and 'cmd'. /// public static string cmd_plugin_description { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx index a2f4cfb64f..991869b8da 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx @@ -121,7 +121,7 @@ Run commands - Executes commands (e.g. 'ping', 'cmd') + Execute system commands like 'ping' and 'cmd' this command has been executed {0} times diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs index f2ec8c1218..0b1e1fe121 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs @@ -160,7 +160,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Adapter Details. + /// Looks up a localized string similar to Adapter details. /// public static string Microsoft_plugin_ext_adapter_details { get { @@ -169,7 +169,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Connection Details. + /// Looks up a localized string similar to Connection details. /// public static string Microsoft_plugin_ext_connection_details { get { @@ -187,7 +187,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Open System Command. + /// Looks up a localized string similar to Open system command. /// public static string Microsoft_plugin_ext_fallback_display_title { get { @@ -205,7 +205,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to System Commands. + /// Looks up a localized string similar to System commands. /// public static string Microsoft_plugin_ext_system_page_name { get { @@ -214,7 +214,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Windows System Commands. + /// Looks up a localized string similar to Windows system commands. /// public static string Microsoft_plugin_ext_system_page_title { get { @@ -628,7 +628,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to You are about to restart this computer, are you sure?. + /// Looks up a localized string similar to You are about to restart this computer. Are you sure?. /// public static string Microsoft_plugin_sys_restart_computer_confirmation { get { @@ -790,7 +790,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to DNS Suffix. + /// Looks up a localized string similar to DNS suffix. /// public static string Microsoft_plugin_sys_Suffix { get { @@ -817,7 +817,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to UEFI Firmware Settings. + /// Looks up a localized string similar to UEFI firmware settings. /// public static string Microsoft_plugin_sys_uefi { get { @@ -826,7 +826,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to You are about to reboot this computer into UEFI Firmware Settings menu, are you sure?. + /// Looks up a localized string similar to You are about to reboot this computer into UEFI firmware settings menu, are you sure?. /// public static string Microsoft_plugin_sys_uefi_confirmation { get { @@ -835,7 +835,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// - /// Looks up a localized string similar to Reboot computer into UEFI Firmware Settings (Requires administrative permissions.). + /// Looks up a localized string similar to Reboot computer into UEFI firmware Settings (requires administrative permissions.). /// public static string Microsoft_plugin_sys_uefi_description { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx index bf7ab04da0..bc8ea5ec38 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx @@ -149,10 +149,10 @@ Shutdown - Connection Details + Connection details - Adapter Details + Adapter details Copy to clipboard @@ -161,10 +161,10 @@ Hide disconnected network info - Windows System Commands + Windows system commands - System Commands + System commands Adapter name @@ -327,7 +327,7 @@ This should align to the action in Windows of a restarting your computer. - You are about to restart this computer, are you sure? + You are about to restart this computer. Are you sure? This should align to the action in Windows of a restarting your computer. @@ -381,7 +381,7 @@ State - DNS Suffix + DNS suffix Tunnel @@ -391,15 +391,15 @@ Means type like category. Here it means network interface type (ethernet, wifi, ...). - UEFI Firmware Settings + UEFI firmware settings This should align to the action in Windows Recovery Environment that restart into uefi settings. - You are about to reboot this computer into UEFI Firmware Settings menu, are you sure? + You are about to reboot this computer into UEFI firmware settings menu, are you sure? This should align to the action in Windows Recovery Environment that restart into uefi settings. - Reboot computer into UEFI Firmware Settings (Requires administrative permissions.) + Reboot computer into UEFI firmware Settings (requires administrative permissions.) This should align to the action in Windows Recovery Environment that restart into uefi settings. @@ -415,7 +415,7 @@ Sleep - Open System Command + Execute system commands Restart Windows Explorer diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs index 54ec578dfa..4bc86c209d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs @@ -9,7 +9,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.System; -public partial class SystemCommandExtensionProvider : CommandProvider +public sealed partial class SystemCommandExtensionProvider : CommandProvider { private readonly ICommandItem[] _commands; private static readonly SettingsManager _settingsManager = new(); @@ -19,7 +19,7 @@ public partial class SystemCommandExtensionProvider : CommandProvider public SystemCommandExtensionProvider() { DisplayName = Resources.Microsoft_plugin_ext_system_page_name; - Id = "System"; + Id = "com.microsoft.cmdpal.builtin.system"; _commands = [ new CommandItem(Page) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs index 9b820bbff8..2884cbbad2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs @@ -17,11 +17,6 @@ public sealed partial class TimeDateCalculator ///
private const string InputDelimiter = "::"; - /// - /// A list of conjunctions that we ignore on search - /// - private static readonly string[] _conjunctionList = Resources.Microsoft_plugin_timedate_Search_ConjunctionList.Split("; "); - /// /// Searches for results /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs index ef4f2ede35..e3443c3ee6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs @@ -349,7 +349,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// - /// Looks up a localized string similar to Time and Date. + /// Looks up a localized string similar to Time and date. /// public static string Microsoft_plugin_timedate_main_page_title { get { @@ -448,7 +448,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// - /// Looks up a localized string similar to Provides time and date values in different formats. + /// Looks up a localized string similar to Show time and date values in different formats. /// public static string Microsoft_plugin_timedate_plugin_description { get { @@ -484,7 +484,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// - /// Looks up a localized string similar to Time and Date. + /// Looks up a localized string similar to Time and date. /// public static string Microsoft_plugin_timedate_plugin_name { get { @@ -501,15 +501,6 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } - /// - /// Looks up a localized string similar to "for; and; nor; but; or; so". - /// - public static string Microsoft_plugin_timedate_Search_ConjunctionList { - get { - return ResourceManager.GetString("Microsoft_plugin_timedate_Search_ConjunctionList", resourceCulture); - } - } - /// /// Looks up a localized string similar to Date and time; Time and Date; Custom format. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx index 35862592ca..eb248e3b1a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx @@ -202,7 +202,7 @@ 'UTC' means here 'Universal Time Convention' - Provides time and date values in different formats + Show time and date values in different formats Do not translate the placeholders like '{0}' because it will be replaced in code. @@ -215,7 +215,7 @@ Time - Time and Date + Time and date RFC1123 @@ -252,10 +252,6 @@ Current Time; Now Don't change order - - for; and; nor; but; or; so - List of conjunctions. We don't add 'yet' because this can be a synonym of 'now' which might be problematic on localized searches. - Second @@ -358,7 +354,7 @@ Error: Invalid input - Time and Date + Time and date A {0}format name{0}, a {0}valid date or time value{0}, or a {0}prefixed number{0}. To search for a format in a specific date/time please use the syntax {0}format::date/time/number{0}.{1}Supported prefixes:{2}'{0}u{0}' for Unix Timestamp{2}'{0}ums{0}' for Unix Timestamp in milliseconds{2}'{0}ft{0}' for Windows file time{2}'{0}oa{0}' for OLE Automation Date{2}'{0}exc{0}' for Excel's 1900 date value{2}'{0}exf{0}' for Excel's 1904 date value diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs index 26bd4d8453..0ad8c339ff 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs @@ -12,7 +12,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.TimeDate; -public partial class TimeDateCommandsProvider : CommandProvider +public sealed partial class TimeDateCommandsProvider : CommandProvider { private readonly CommandItem _command; private static readonly SettingsManager _settingsManager = new SettingsManager(); @@ -23,7 +23,7 @@ public partial class TimeDateCommandsProvider : CommandProvider public TimeDateCommandsProvider() { DisplayName = Resources.Microsoft_plugin_timedate_plugin_name; - Id = "DateTime"; + Id = "com.microsoft.cmdpal.builtin.datetime"; _command = new CommandItem(_timeDateExtensionPage) { Icon = _timeDateExtensionPage.Icon, diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs index 9087ce0ee1..1a15991120 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs @@ -11,7 +11,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WebSearch; -public partial class WebSearchCommandsProvider : CommandProvider +public sealed partial class WebSearchCommandsProvider : CommandProvider { private readonly SettingsManager _settingsManager = new(); private readonly FallbackExecuteSearchItem _fallbackItem; @@ -22,7 +22,7 @@ public partial class WebSearchCommandsProvider : CommandProvider public WebSearchCommandsProvider() { - Id = "WebSearch"; + Id = "com.microsoft.cmdpal.builtin.websearch"; DisplayName = Resources.extension_name; Icon = Icons.WebSearch; Settings = _settingsManager.Settings; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs index 418cccf58c..e4567a30d4 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs @@ -124,16 +124,7 @@ namespace Microsoft.CmdPal.Ext.WinGet.Properties { } /// - /// Looks up a localized string similar to Search for extensions on WinGet. - /// - public static string winget_install_extensions_subtitle { - get { - return ResourceManager.GetString("winget_install_extensions_subtitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Install Command Palette extensions. + /// Looks up a localized string similar to Add Command Palette extensions from WinGet. /// public static string winget_install_extensions_title { get { @@ -205,7 +196,7 @@ namespace Microsoft.CmdPal.Ext.WinGet.Properties { } /// - /// Looks up a localized string similar to Search WinGet. + /// Looks up a localized string similar to Find apps on WinGet. /// public static string winget_page_name { get { @@ -268,7 +259,7 @@ namespace Microsoft.CmdPal.Ext.WinGet.Properties { } /// - /// Looks up a localized string similar to Search for extensions in the Store. + /// Looks up a localized string similar to Add Command Palette extensions from the Microsoft Store. /// public static string winget_search_store_title { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx index ac2128430d..4a6df0b518 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx @@ -127,19 +127,15 @@ - Install Command Palette extensions - - - - Search for extensions on WinGet + Add Command Palette extensions from WinGet - Search for extensions in the Store + Add Command Palette extensions from the Microsoft Store - Search WinGet + Find apps on WinGet diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs index 44b6da1f17..14c7ec0831 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs @@ -27,7 +27,6 @@ public partial class WinGetExtensionCommandsProvider : CommandProvider new WinGetExtensionPage(WinGetExtensionPage.ExtensionsTag) { Title = Properties.Resources.winget_install_extensions_title }) { Title = Properties.Resources.winget_install_extensions_title, - Subtitle = Properties.Resources.winget_install_extensions_subtitle, }, new ListItem( diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs index 1a8c5106d4..0d0cce13c6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs @@ -70,7 +70,7 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { } /// - /// Looks up a localized string similar to On all Desktops. + /// Looks up a localized string similar to On all desktops. /// public static string VirtualDesktopHelper_AllDesktops { get { @@ -196,7 +196,7 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { } /// - /// Looks up a localized string similar to Not Responding. + /// Looks up a localized string similar to Not responding. /// public static string windowwalker_NotResponding { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx index 3d61936a1d..cbfa9b69b0 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx @@ -209,7 +209,7 @@ When disabled, windows will be sorted by title - Not Responding + Not responding Switch between open windows @@ -218,7 +218,7 @@ Switch to - On all Desktops + On all desktops Desktop {0} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs index 48ca848707..c48dcd99b2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs @@ -23,8 +23,7 @@ public partial class WindowsServicesCommandsProvider : CommandProvider return [ new CommandItem(new ServicesListPage()) { - Title = "Windows Services", - Subtitle = "Manage Windows Services", + Title = "Manage Windows services", } ]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs index 600e621c99..dd13d5295a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs @@ -10,7 +10,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WindowsSettings; -public partial class WindowsSettingsCommandsProvider : CommandProvider +public sealed partial class WindowsSettingsCommandsProvider : CommandProvider { private readonly CommandItem _searchSettingsListItem; @@ -22,7 +22,7 @@ public partial class WindowsSettingsCommandsProvider : CommandProvider public WindowsSettingsCommandsProvider() { - Id = "Windows.Settings"; + Id = "com.microsoft.cmdpal.builtin.windowssettings"; DisplayName = Resources.WindowsSettingsProvider_DisplayName; Icon = Icons.WindowsSettingsIcon; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs index 81d82638f0..46b0d185b6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs @@ -97,7 +97,7 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Properties { } /// - /// Looks up a localized string similar to Open Windows Terminal Profiles. + /// Looks up a localized string similar to Open Windows Terminal profiles. /// internal static string list_item_title { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx index 23a1533fab..bff603bedc 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx @@ -156,7 +156,7 @@ Settings - Open Windows Terminal Profiles + Open Windows Terminal profiles Preferred channel diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPageWithDetails.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPageWithDetails.cs index 018c11720c..abf80e3d1e 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPageWithDetails.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPageWithDetails.cs @@ -63,7 +63,7 @@ internal sealed partial class SampleListPageWithDetails : ListPage Details = new Details() { Title = "Hero Image Example", - HeroImage = new IconInfo("https://m.media-amazon.com/images/M/MV5BNDBkMzVmNGQtYTM2OC00OWRjLTk5OWMtNzNkMDI4NjFjNTZmXkEyXkFqcGdeQXZ3ZXNsZXk@._V1_QL75_UX500_CR0,0,500,281_.jpg"), + HeroImage = new IconInfo("https://m.media-amazon.com/images/M/MV5BNDBkMzVmNGQtYTM2OC00OWRjLTk5OWMtNzNkMDI4NjFjNTZmXkEyXkFqcGdeQXZ3ZXNsZXk@._V1_QL75_UX500_CR0,0,500,281_.jpg"), /* #no-spell-check-line */ Body = "It is literally an image of a hero", }, }, diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj index 4007e6a986..964211ddff 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj @@ -62,10 +62,18 @@ true - + + true true true + + + false + false + false + + diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs index 6a0dde88cc..98e2fae688 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs @@ -15,6 +15,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "Dictionary")] +[JsonSerializable(typeof(List>))] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true)] internal sealed partial class JsonSerializationContext : JsonSerializerContext { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs index cdb7b72b25..87beb49075 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs @@ -26,15 +26,69 @@ public sealed class ToggleSetting : Setting public override Dictionary ToDictionary() { - return new Dictionary + var items = new List>(); + + if (!string.IsNullOrEmpty(Label)) { - { "type", "Input.Toggle" }, - { "title", Label }, - { "id", Key }, - { "label", Description }, - { "value", JsonSerializer.Serialize(Value, JsonSerializationContext.Default.Boolean) }, - { "isRequired", IsRequired }, - { "errorMessage", ErrorMessage }, + items.Add( + new() + { + { "type", "TextBlock" }, + { "text", Label }, + { "wrap", true }, + }); + } + + if (!(string.IsNullOrEmpty(Description) || string.Equals(Description, Label, StringComparison.OrdinalIgnoreCase))) + { + items.Add( + new() + { + { "type", "TextBlock" }, + { "text", Description }, + { "isSubtle", true }, + { "size", "Small" }, + { "spacing", "Small" }, + { "wrap", true }, + }); + } + + return new() + { + { "type", "ColumnSet" }, + { + "columns", new List> + { + new() + { + { "type", "Column" }, + { "width", "20px" }, + { + "items", new List> + { + new() + { + { "type", "Input.Toggle" }, + { "title", " " }, + { "id", Key }, + { "value", JsonSerializer.Serialize(Value, JsonSerializationContext.Default.Boolean) }, + { "isRequired", IsRequired }, + { "errorMessage", ErrorMessage }, + }, + } + }, + { "verticalContentAlignment", "Center" }, + }, + new() + { + { "type", "Column" }, + { "width", "stretch" }, + { "items", items }, + { "verticalContentAlignment", "Center" }, + }, + } + }, + { "spacing", "Medium" }, }; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj index 92baf3dfa6..a6cad871ab 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj @@ -68,6 +68,34 @@ true false + + + + MultiThreadedDebug + + + + %(IgnoreSpecificDefaultLibraries);libucrtd.lib + %(AdditionalOptions) /defaultlib:ucrtd.lib /profile /opt:ref /opt:icf + + + + + + MultiThreaded + + + + %(IgnoreSpecificDefaultLibraries);libucrt.lib + %(AdditionalOptions) /defaultlib:ucrt.lib /profile /opt:ref /opt:icf + + diff --git a/src/modules/fancyzones/FancyZones/FancyZones.vcxproj b/src/modules/fancyzones/FancyZones/FancyZones.vcxproj index 41ae8f0227..2f9c9aa9d9 100644 --- a/src/modules/fancyzones/FancyZones/FancyZones.vcxproj +++ b/src/modules/fancyzones/FancyZones/FancyZones.vcxproj @@ -26,6 +26,7 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true + MultiThreadedDebug true @@ -36,6 +37,7 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false + MultiThreaded true true diff --git a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs index 36a17ceb19..f45fc28e6a 100644 --- a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs +++ b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs @@ -10,7 +10,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; - +using ImageResizer.Properties; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Moq.Protected; @@ -101,7 +101,9 @@ namespace ImageResizer.Models private static ResizeBatch CreateBatch(Action executeAction) { var mock = new Mock { CallBase = true }; - mock.Protected().Setup("Execute", ItExpr.IsAny()).Callback(executeAction); + mock.Protected() + .Setup("Execute", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((string file, Settings settings) => executeAction(file)); return mock.Object; } diff --git a/src/modules/imageresizer/ui/ImageResizerUI.csproj b/src/modules/imageresizer/ui/ImageResizerUI.csproj index b146db8435..3ce98d8386 100644 --- a/src/modules/imageresizer/ui/ImageResizerUI.csproj +++ b/src/modules/imageresizer/ui/ImageResizerUI.csproj @@ -24,13 +24,13 @@ Resources\ImageResizer.ico - + diff --git a/src/modules/imageresizer/ui/Models/ResizeBatch.cs b/src/modules/imageresizer/ui/Models/ResizeBatch.cs index 1181395c09..87e0b84e7b 100644 --- a/src/modules/imageresizer/ui/Models/ResizeBatch.cs +++ b/src/modules/imageresizer/ui/Models/ResizeBatch.cs @@ -87,9 +87,14 @@ namespace ImageResizer.Models public IEnumerable Process(Action reportProgress, CancellationToken cancellationToken) { double total = Files.Count; - var completed = 0; + int completed = 0; var errors = new ConcurrentBag(); + // NOTE: Settings.Default is captured once before parallel processing. + // Any changes to settings on disk during this batch will NOT be reflected until the next batch. + // This improves performance and predictability by avoiding repeated mutex acquisition and behaviour change results in a batch. + var settings = Settings.Default; + // TODO: If we ever switch to Windows.Graphics.Imaging, we can get a lot more throughput by using the async // APIs and a custom SynchronizationContext Parallel.ForEach( @@ -97,13 +102,12 @@ namespace ImageResizer.Models new ParallelOptions { CancellationToken = cancellationToken, - MaxDegreeOfParallelism = Environment.ProcessorCount, }, (file, state, i) => { try { - Execute(file); + Execute(file, settings); } catch (Exception ex) { @@ -111,14 +115,13 @@ namespace ImageResizer.Models } Interlocked.Increment(ref completed); - reportProgress(completed, total); }); return errors; } - protected virtual void Execute(string file) - => new ResizeOperation(file, DestinationDirectory, Settings.Default).Execute(); + protected virtual void Execute(string file, Settings settings) + => new ResizeOperation(file, DestinationDirectory, settings).Execute(); } } diff --git a/src/modules/imageresizer/ui/Properties/Settings.cs b/src/modules/imageresizer/ui/Properties/Settings.cs index debb26a191..0f8690dcbb 100644 --- a/src/modules/imageresizer/ui/Properties/Settings.cs +++ b/src/modules/imageresizer/ui/Properties/Settings.cs @@ -461,33 +461,42 @@ namespace ImageResizer.Properties { } - // Needs to be called on the App UI thread as the properties are bound to the UI. - App.Current.Dispatcher.Invoke(() => + if (App.Current?.Dispatcher != null) { - ShrinkOnly = jsonSettings.ShrinkOnly; - Replace = jsonSettings.Replace; - IgnoreOrientation = jsonSettings.IgnoreOrientation; - RemoveMetadata = jsonSettings.RemoveMetadata; - JpegQualityLevel = jsonSettings.JpegQualityLevel; - PngInterlaceOption = jsonSettings.PngInterlaceOption; - TiffCompressOption = jsonSettings.TiffCompressOption; - FileName = jsonSettings.FileName; - KeepDateModified = jsonSettings.KeepDateModified; - FallbackEncoder = jsonSettings.FallbackEncoder; - CustomSize = jsonSettings.CustomSize; - SelectedSizeIndex = jsonSettings.SelectedSizeIndex; - - if (jsonSettings.Sizes.Count > 0) - { - Sizes.Clear(); - Sizes.AddRange(jsonSettings.Sizes); - - // Ensure Ids are unique and handle missing Ids - IdRecoveryHelper.RecoverInvalidIds(Sizes); - } - }); + // Needs to be called on the App UI thread as the properties are bound to the UI. + App.Current.Dispatcher.Invoke(() => ReloadCore(jsonSettings)); + } + else + { + ReloadCore(jsonSettings); + } _jsonMutex.ReleaseMutex(); } + + private void ReloadCore(Settings jsonSettings) + { + ShrinkOnly = jsonSettings.ShrinkOnly; + Replace = jsonSettings.Replace; + IgnoreOrientation = jsonSettings.IgnoreOrientation; + RemoveMetadata = jsonSettings.RemoveMetadata; + JpegQualityLevel = jsonSettings.JpegQualityLevel; + PngInterlaceOption = jsonSettings.PngInterlaceOption; + TiffCompressOption = jsonSettings.TiffCompressOption; + FileName = jsonSettings.FileName; + KeepDateModified = jsonSettings.KeepDateModified; + FallbackEncoder = jsonSettings.FallbackEncoder; + CustomSize = jsonSettings.CustomSize; + SelectedSizeIndex = jsonSettings.SelectedSizeIndex; + + if (jsonSettings.Sizes.Count > 0) + { + Sizes.Clear(); + Sizes.AddRange(jsonSettings.Sizes); + + // Ensure Ids are unique and handle missing Ids + IdRecoveryHelper.RecoverInvalidIds(Sizes); + } + } } } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj index e2e2093641..98a82a314a 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj @@ -30,6 +30,7 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true + MultiThreadedDebug true @@ -40,6 +41,7 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false + MultiThreaded true true diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png index 4228da1e88..7a96d92df1 100644 Binary files a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png and b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png differ diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png index 6c4dcc5ae5..580cf3f609 100644 Binary files a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png and b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png differ diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs index d08ec588d3..a6cec9880d 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs @@ -74,7 +74,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.UnitTests var result = main.Object.Query(expectedQuery).FirstOrDefault().SubTitle; // Assert - Assert.AreEqual("Reboot computer into UEFI Firmware Settings (Requires administrative permissions.)", result); + Assert.AreEqual("Reboot computer into UEFI firmware settings (Requires administrative permissions.)", result); } [TestMethod] diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs index 5d07afd41f..a530fbc580 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -682,7 +682,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { } /// - /// Looks up a localized string similar to You are about to reboot this computer into UEFI Firmware Settings menu, are you sure?. + /// Looks up a localized string similar to You are about to reboot this computer into UEFI firmware settings menu, are you sure?. /// internal static string Microsoft_plugin_sys_uefi_confirmation { get { @@ -691,7 +691,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { } /// - /// Looks up a localized string similar to Reboot computer into UEFI Firmware Settings (Requires administrative permissions.). + /// Looks up a localized string similar to Reboot computer into UEFI firmware settings (Requires administrative permissions.). /// internal static string Microsoft_plugin_sys_uefi_description { get { diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx index eeaa8a423b..f9d1deaab2 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx @@ -362,15 +362,15 @@ Means type like category. Here it means network interface type (ethernet, wifi, ...). - UEFI Firmware Settings + UEFI firmware settings This should align to the action in Windows Recovery Environment that restart into uefi settings. - You are about to reboot this computer into UEFI Firmware Settings menu, are you sure? + You are about to reboot this computer into UEFI firmware settings menu, are you sure? This should align to the action in Windows Recovery Environment that restart into uefi settings. - Reboot computer into UEFI Firmware Settings (Requires administrative permissions.) + Reboot computer into UEFI firmware settings (Requires administrative permissions.) This should align to the action in Windows Recovery Environment that restart into uefi settings. diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx index e1e3a68766..84d81c1b96 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx @@ -208,7 +208,7 @@ 'UTC' means here 'Universal Time Convention' - Provides time and date values for the system time or a custom time stamp (e.g.'{0}', '{1}', '{2}', '{3}') + Shows time and date values for the system time or a custom time stamp (e.g.'{0}', '{1}', '{2}', '{3}') Do not translate the placeholders like '{0}' because it will be replaced in code. diff --git a/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs b/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs index 5003a02cae..4ff1a08697 100644 --- a/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs +++ b/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs @@ -61,7 +61,7 @@ namespace PowerLauncher.Helper // Many bug reports because users see the "Report problem UI" after "the" crash with System.Runtime.InteropServices.COMException 0xD0000701 or 0x80263001. // However, displaying this "Report problem UI" during WPF crashes, especially when DWM composition is changing, is not ideal; some users reported it hangs for up to a minute before the "Report problem UI" appears. // This change modifies the behavior to log the exception instead of showing the "Report problem UI". - if (IsDwmCompositionException(e as System.Runtime.InteropServices.COMException)) + if (ExceptionHelper.IsRecoverableDwmCompositionException(e as System.Runtime.InteropServices.COMException)) { var logger = LogManager.GetLogger(LoggerName); logger.Error($"From {(isNotUIThread ? "non" : string.Empty)} UI thread's exception: {ExceptionFormatter.FormatException(e)}"); @@ -91,22 +91,5 @@ namespace PowerLauncher.Helper } } } - - private static bool IsDwmCompositionException(System.Runtime.InteropServices.COMException comException) - { - if (comException == null) - { - return false; - } - - var stackTrace = comException.StackTrace; - if (string.IsNullOrEmpty(stackTrace)) - { - return false; - } - - // Check for common DWM composition changed patterns in the stack trace - return stackTrace.Contains("DwmCompositionChanged"); - } } } diff --git a/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs b/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs new file mode 100644 index 0000000000..15e7de4eac --- /dev/null +++ b/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace PowerLauncher.Helper +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Win32 naming conventions")] + internal static class ExceptionHelper + { + private const string PresentationFrameworkExceptionSource = "PresentationFramework"; + + private const int DWM_E_COMPOSITIONDISABLED = unchecked((int)0x80263001); + + // HRESULT for NT STATUS STATUS_MESSAGE_LOST (0xC0000701 | 0x10000000 == 0xD0000701) + private const int STATUS_MESSAGE_LOST_HR = unchecked((int)0xD0000701); + + /// + /// Returns true if the exception is a recoverable DWM composition exception. + /// + internal static bool IsRecoverableDwmCompositionException(Exception exception) + { + if (exception is not COMException comException) + { + return false; + } + + if (comException.HResult is DWM_E_COMPOSITIONDISABLED) + { + return true; + } + + if (comException.HResult is STATUS_MESSAGE_LOST_HR && comException.Source == PresentationFrameworkExceptionSource) + { + return true; + } + + // Check for common DWM composition changed patterns in the stack trace + var stackTrace = comException.StackTrace; + return !string.IsNullOrEmpty(stackTrace) && + stackTrace.Contains("DwmCompositionChanged"); + } + } +} diff --git a/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs b/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs index 2a27494b30..53cc841b30 100644 --- a/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs +++ b/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs @@ -3,13 +3,16 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Media; using ManagedCommon; using Microsoft.Win32; using Wox.Infrastructure.Image; using Wox.Infrastructure.UserSettings; +using Wox.Plugin.Logger; namespace PowerLauncher.Helper { @@ -20,6 +23,9 @@ namespace PowerLauncher.Helper private readonly ThemeHelper _themeHelper = new(); private bool _disposed; + private CancellationTokenSource _themeUpdateTokenSource; + private const int MaxRetries = 5; + private const int InitialDelayMs = 2000; public Theme CurrentTheme { get; private set; } @@ -108,10 +114,80 @@ namespace PowerLauncher.Helper { Theme newTheme = _themeHelper.DetermineTheme(_settings.Theme); - _mainWindow.Dispatcher.Invoke(() => + // Cancel any existing theme update operation + _themeUpdateTokenSource?.Cancel(); + _themeUpdateTokenSource?.Dispose(); + _themeUpdateTokenSource = new CancellationTokenSource(); + + // Start theme update with retry logic in the background + _ = UpdateThemeWithRetryAsync(newTheme, _themeUpdateTokenSource.Token); + } + + /// + /// Applies the theme with retry logic for desktop composition errors. + /// + /// The theme to apply. + /// Token to cancel the operation. + private async Task UpdateThemeWithRetryAsync(Theme theme, CancellationToken cancellationToken) + { + var delayMs = 0; + const int maxAttempts = MaxRetries + 1; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { - SetSystemTheme(newTheme); - }); + try + { + if (delayMs > 0) + { + await Task.Delay(delayMs, cancellationToken); + } + + if (cancellationToken.IsCancellationRequested) + { + Log.Debug("Theme update operation was cancelled.", typeof(ThemeManager)); + return; + } + + await _mainWindow.Dispatcher.InvokeAsync(() => + { + SetSystemTheme(theme); + }); + + if (attempt > 1) + { + Log.Info($"Successfully applied theme after {attempt - 1} retry attempt(s).", typeof(ThemeManager)); + } + + return; + } + catch (COMException ex) when (ExceptionHelper.IsRecoverableDwmCompositionException(ex)) + { + switch (attempt) + { + case 1: + Log.Warn($"Desktop composition is disabled (HRESULT: 0x{ex.HResult:X}). Scheduling retries for theme update.", typeof(ThemeManager)); + delayMs = InitialDelayMs; + break; + case < maxAttempts: + Log.Warn($"Retry {attempt - 1}/{MaxRetries} failed: Desktop composition still disabled. Retrying in {delayMs * 2}ms...", typeof(ThemeManager)); + delayMs *= 2; + break; + default: + Log.Exception($"Failed to set theme after {MaxRetries} retry attempts. Desktop composition remains disabled.", ex, typeof(ThemeManager)); + break; + } + } + catch (OperationCanceledException) + { + Log.Debug("Theme update operation was cancelled.", typeof(ThemeManager)); + return; + } + catch (Exception ex) + { + Log.Exception($"Unexpected error during theme update (attempt {attempt}/{maxAttempts}): {ex.Message}", ex, typeof(ThemeManager)); + throw; + } + } } public void Dispose() @@ -130,6 +206,8 @@ namespace PowerLauncher.Helper if (disposing) { SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; + _themeUpdateTokenSource?.Cancel(); + _themeUpdateTokenSource?.Dispose(); } _disposed = true; diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs index 586b92ed75..a3721a04ec 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs @@ -24,13 +24,15 @@ using Windows.Storage; namespace Peek.FilePreviewer.Previewers.MediaPreviewer { - public partial class AudioPreviewer : ObservableObject, IAudioPreviewer + public partial class AudioPreviewer : ObservableObject, IDisposable, IAudioPreviewer { + private MediaSource? _mediaSource; + [ObservableProperty] private PreviewState _state; [ObservableProperty] - private AudioPreviewData _preview; + private AudioPreviewData? _preview; private IFileSystemItem Item { get; } @@ -40,7 +42,6 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { Item = file; Dispatcher = DispatcherQueue.GetForCurrentThread(); - Preview = new AudioPreviewData(); } public async Task CopyAsync() @@ -63,19 +64,23 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { State = PreviewState.Loading; + Preview = new AudioPreviewData(); + var thumbnailTask = LoadThumbnailAsync(cancellationToken); var sourceTask = LoadSourceAsync(cancellationToken); var metadataTask = LoadMetadataAsync(cancellationToken); await Task.WhenAll(thumbnailTask, sourceTask, metadataTask); - if (!thumbnailTask.Result || !sourceTask.Result || !metadataTask.Result) + if (sourceTask.Result && metadataTask.Result) { - State = PreviewState.Error; + State = PreviewState.Loaded; } else { - State = PreviewState.Loaded; + // Release all resources on error. + Unload(); + State = PreviewState.Error; } } @@ -88,12 +93,15 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { cancellationToken.ThrowIfCancellationRequested(); - var thumbnail = await ThumbnailHelper.GetThumbnailAsync(Item.Path, cancellationToken) - ?? await ThumbnailHelper.GetIconAsync(Item.Path, cancellationToken); + if (Preview != null) + { + var thumbnail = await ThumbnailHelper.GetThumbnailAsync(Item.Path, cancellationToken) + ?? await ThumbnailHelper.GetIconAsync(Item.Path, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - Preview.Thumbnail = thumbnail ?? new SvgImageSource(new Uri("ms-appx:///Assets/Peek/DefaultFileIcon.svg")); + Preview.Thumbnail = thumbnail ?? new SvgImageSource(new Uri("ms-appx:///Assets/Peek/DefaultFileIcon.svg")); + } }); }); } @@ -110,7 +118,11 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { cancellationToken.ThrowIfCancellationRequested(); - Preview.MediaSource = MediaSource.CreateFromStorageFile(storageFile); + if (Preview != null) + { + _mediaSource = MediaSource.CreateFromStorageFile(storageFile); + Preview.MediaSource = _mediaSource; + } }); }); } @@ -123,6 +135,11 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer await Dispatcher.RunOnUiThread(() => { + if (Preview == null) + { + return; + } + cancellationToken.ThrowIfCancellationRequested(); Preview.Title = PropertyStoreHelper.TryGetStringProperty(Item.Path, PropertyKey.MusicTitle) ?? Item.Name[..^Item.Extension.Length]; @@ -160,6 +177,22 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer return _supportedFileTypes.Contains(item.Extension); } + public void Dispose() + { + Unload(); + GC.SuppressFinalize(this); + } + + /// + /// Explicitly unloads the preview and releases file resources. + /// + public void Unload() + { + _mediaSource?.Dispose(); + _mediaSource = null; + Preview = null; + } + private static readonly HashSet _supportedFileTypes = new() { ".aac", diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs index b9de53e87b..061d3eca47 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs @@ -25,6 +25,8 @@ namespace Peek.FilePreviewer.Previewers { public partial class VideoPreviewer : ObservableObject, IVideoPreviewer, IDisposable { + private MediaSource? _mediaSource; + [ObservableProperty] private MediaSource? preview; @@ -56,6 +58,7 @@ namespace Peek.FilePreviewer.Previewers public void Dispose() { + Unload(); GC.SuppressFinalize(this); } @@ -145,7 +148,8 @@ namespace Peek.FilePreviewer.Previewers MissingCodecName = missingCodecName; } - Preview = MediaSource.CreateFromStorageFile(storageFile); + _mediaSource = MediaSource.CreateFromStorageFile(storageFile); + Preview = _mediaSource; }); }); } @@ -155,6 +159,16 @@ namespace Peek.FilePreviewer.Previewers return !(VideoTask?.Result ?? true); } + /// + /// Explicitly unloads the preview and releases file resources. + /// + public void Unload() + { + _mediaSource?.Dispose(); + _mediaSource = null; + Preview = null; + } + private static readonly HashSet _supportedFileTypes = new() { ".mp4", ".3g2", ".3gp", ".3gp2", ".3gpp", ".asf", ".avi", ".m2t", ".m2ts", diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index db54e346cc..775664c042 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -62,6 +62,12 @@ namespace Peek.UI [ObservableProperty] private IFileSystemItem? _currentItem; + /// + /// Work around missing navigation when peeking from CLI. + /// TODO: Implement navigation when peeking from CLI. + /// + private bool _isFromCli; + partial void OnCurrentItemChanged(IFileSystemItem? value) { WindowTitle = value != null @@ -129,7 +135,24 @@ namespace Peek.UI NavigationThrottleTimer.Interval = TimeSpan.FromMilliseconds(NavigationThrottleDelayMs); } - public void Initialize(HWND foregroundWindowHandle) + public void Initialize(SelectedItem selectedItem) + { + switch (selectedItem) + { + case SelectedItemByPath selectedItemByPath: + InitializeFromCli(selectedItemByPath.Path); + break; + + case SelectedItemByWindowHandle selectedItemByWindowHandle: + InitializeFromExplorer(selectedItemByWindowHandle.WindowHandle); + break; + + default: + throw new NotImplementedException($"Invalid type of selected item: '{selectedItem.GetType().FullName}'"); + } + } + + private void InitializeFromExplorer(HWND foregroundWindowHandle) { try { @@ -141,10 +164,20 @@ namespace Peek.UI } _currentIndex = DisplayIndex = 0; + _isFromCli = false; CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null; } + private void InitializeFromCli(string path) + { + // TODO: implement navigation + _isFromCli = true; + Items = null; + _currentIndex = DisplayIndex = 0; + CurrentItem = new FileItem(path, Path.GetFileName(path)); + } + public void Uninitialize() { _currentIndex = DisplayIndex = 0; @@ -153,6 +186,7 @@ namespace Peek.UI Items = null; _navigationDirection = NavigationDirection.Forwards; IsErrorVisible = false; + _isFromCli = false; } public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards); @@ -166,6 +200,12 @@ namespace Peek.UI return; } + // TODO: implement navigation. + if (_isFromCli) + { + return; + } + if (Items == null || Items.Count == _deletedItemIndexes.Count) { _currentIndex = DisplayIndex = 0; diff --git a/src/modules/peek/Peek.UI/Models/SelectedItem.cs b/src/modules/peek/Peek.UI/Models/SelectedItem.cs new file mode 100644 index 0000000000..9d0cc6568a --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItem.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Peek.UI.Models +{ + public abstract class SelectedItem + { + public abstract bool Matches(string? path); + } +} diff --git a/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs b/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs new file mode 100644 index 0000000000..5f53865bfd --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Peek.UI.Models +{ + public class SelectedItemByPath : SelectedItem + { + public string Path { get; } + + public SelectedItemByPath(string path) + { + Path = path; + } + + public override bool Matches(string? path) + { + return string.Equals(Path, path, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs b/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs new file mode 100644 index 0000000000..e93b2f94ca --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Peek.UI.Extensions; +using Peek.UI.Helpers; +using Windows.Win32.Foundation; + +namespace Peek.UI.Models +{ + public class SelectedItemByWindowHandle : SelectedItem + { + public HWND WindowHandle { get; } + + public SelectedItemByWindowHandle(HWND windowHandle) + { + WindowHandle = windowHandle; + } + + public override bool Matches(string? path) + { + var selectedItems = FileExplorerHelper.GetSelectedItems(WindowHandle); + var selectedItemsCount = selectedItems?.GetCount() ?? 0; + if (selectedItems == null || selectedItemsCount == 0 || selectedItemsCount > 1) + { + return false; + } + + var fileExplorerSelectedItemPath = selectedItems.GetItemAt(0).ToIFileSystemItem().Path; + var currentItemPath = path; + return fileExplorerSelectedItemPath != null && currentItemPath != null && fileExplorerSelectedItemPath != currentItemPath; + } + } +} diff --git a/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs index 9bd66e380f..b89e871a4d 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs @@ -3,16 +3,19 @@ // See the LICENSE file in the project root for more information. using System; - +using System.IO; +using System.Threading; using ManagedCommon; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls.Primitives; using Peek.Common; using Peek.FilePreviewer; using Peek.FilePreviewer.Models; using Peek.FilePreviewer.Previewers; +using Peek.UI.Models; using Peek.UI.Native; using Peek.UI.Telemetry.Events; using Peek.UI.Views; @@ -23,7 +26,7 @@ namespace Peek.UI /// /// Provides application-specific behavior to supplement the default Application class. /// - public partial class App : Application, IApp + public partial class App : Application, IApp, IDisposable { public static int PowerToysPID { get; set; } @@ -36,6 +39,10 @@ namespace Peek.UI private MainWindow? Window { get; set; } + private bool _disposed; + private SelectedItem? _selectedItem; + private bool _launchedFromCli; + /// /// Initializes a new instance of the class. /// Initializes the singleton application object. This is the first line of authored code @@ -52,22 +59,22 @@ namespace Peek.UI InitializeComponent(); Logger.InitializeLogger("\\Peek\\Logs"); - Host = Microsoft.Extensions.Hosting.Host. - CreateDefaultBuilder(). - UseContentRoot(AppContext.BaseDirectory). - ConfigureServices((context, services) => - { - // Core Services - services.AddTransient(); - services.AddSingleton(); - services.AddSingleton(); + Host = Microsoft.Extensions.Hosting.Host + .CreateDefaultBuilder() + .UseContentRoot(AppContext.BaseDirectory) + .ConfigureServices((context, services) => + { + // Core Services + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); - // Views and ViewModels - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - }). - Build(); + // Views and ViewModels + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + }) + .Build(); UnhandledException += App_UnhandledException; } @@ -99,6 +106,7 @@ namespace Peek.UI var cmdArgs = Environment.GetCommandLineArgs(); if (cmdArgs?.Length > 1) { + // Check if the last argument is a PowerToys Runner PID if (int.TryParse(cmdArgs[^1], out int powerToysRunnerPid)) { RunnerHelper.WaitForPowerToysRunner(powerToysRunnerPid, () => @@ -107,9 +115,25 @@ namespace Peek.UI Environment.Exit(0); }); } + else + { + // Command line argument is a file path - activate Peek with that file + string filePath = cmdArgs[^1]; + if (File.Exists(filePath) || Directory.Exists(filePath)) + { + _selectedItem = new SelectedItemByPath(filePath); + _launchedFromCli = true; + OnShowPeek(); + return; + } + else + { + Logger.LogError($"Command line argument is not a valid file or directory: {filePath}"); + } + } } - NativeEventWaiter.WaitForEventLoop(Constants.ShowPeekEvent(), OnPeekHotkey); + NativeEventWaiter.WaitForEventLoop(Constants.ShowPeekEvent(), OnShowPeek); NativeEventWaiter.WaitForEventLoop(Constants.TerminatePeekEvent(), () => { ShellPreviewHandlerPreviewer.ReleaseHandlerFactories(); @@ -126,11 +150,16 @@ namespace Peek.UI /// /// Handle Peek hotkey /// - private void OnPeekHotkey() + private void OnShowPeek() { - // Need to read the foreground HWND before activating Peek to avoid focus stealing - // Foreground HWND must always be Explorer or Desktop - var foregroundWindowHandle = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow(); + // null means explorer, not null means CLI + if (_selectedItem == null) + { + // Need to read the foreground HWND before activating Peek to avoid focus stealing + // Foreground HWND must always be Explorer or Desktop + var foregroundWindowHandle = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow(); + _selectedItem = new SelectedItemByWindowHandle(foregroundWindowHandle); + } bool firstActivation = false; @@ -140,7 +169,38 @@ namespace Peek.UI Window = new MainWindow(); } - Window.Toggle(firstActivation, foregroundWindowHandle); + Window.Toggle(firstActivation, _selectedItem, _launchedFromCli); + _launchedFromCli = false; + _selectedItem = null; + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // dispose managed state (managed objects) + } + + // free unmanaged resources (unmanaged objects) and override finalizer + // set large fields to null + _disposed = true; + } + } + + /* // override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + // ~App() + // { + // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + // Dispose(disposing: false); + // } */ + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); } } } diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index 4edad9a807..2c8983c634 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -18,6 +18,7 @@ using Peek.FilePreviewer.Models; using Peek.FilePreviewer.Previewers; using Peek.UI.Extensions; using Peek.UI.Helpers; +using Peek.UI.Models; using Peek.UI.Telemetry.Events; using Windows.Foundation; using WinUIEx; @@ -38,6 +39,7 @@ namespace Peek.UI /// dialog is open at a time. /// private bool _isDeleteInProgress; + private bool _exitAfterClose; public MainWindow() { @@ -116,12 +118,17 @@ namespace Peek.UI /// /// Toggling the window visibility and querying files when necessary. /// - public void Toggle(bool firstActivation, Windows.Win32.Foundation.HWND foregroundWindowHandle) + public void Toggle(bool firstActivation, SelectedItem selectedItem, bool exitAfterClose) { + if (exitAfterClose) + { + _exitAfterClose = true; + } + if (firstActivation) { Activate(); - Initialize(foregroundWindowHandle); + Initialize(selectedItem); return; } @@ -132,9 +139,9 @@ namespace Peek.UI if (AppWindow.IsVisible) { - if (IsNewSingleSelectedItem(foregroundWindowHandle)) + if (IsNewSingleSelectedItem(selectedItem)) { - Initialize(foregroundWindowHandle); + Initialize(selectedItem); Activate(); // Brings existing window into focus in case it was previously minimized } else @@ -144,7 +151,7 @@ namespace Peek.UI } else { - Initialize(foregroundWindowHandle); + Initialize(selectedItem); } } @@ -182,12 +189,12 @@ namespace Peek.UI Uninitialize(); } - private void Initialize(Windows.Win32.Foundation.HWND foregroundWindowHandle) + private void Initialize(SelectedItem selectedItem) { var bootTime = new System.Diagnostics.Stopwatch(); bootTime.Start(); - ViewModel.Initialize(foregroundWindowHandle); + ViewModel.Initialize(selectedItem); ViewModel.ScalingFactor = this.GetMonitorScale(); this.Content.KeyUp += Content_KeyUp; @@ -207,6 +214,11 @@ namespace Peek.UI this.Content.KeyUp -= Content_KeyUp; ShellPreviewHandlerPreviewer.ReleaseHandlerFactories(); + + if (_exitAfterClose) + { + Environment.Exit(0); + } } /// @@ -272,20 +284,11 @@ namespace Peek.UI Uninitialize(); } - private bool IsNewSingleSelectedItem(Windows.Win32.Foundation.HWND foregroundWindowHandle) + private bool IsNewSingleSelectedItem(SelectedItem selectedItem) { try { - var selectedItems = FileExplorerHelper.GetSelectedItems(foregroundWindowHandle); - var selectedItemsCount = selectedItems?.GetCount() ?? 0; - if (selectedItems == null || selectedItemsCount == 0 || selectedItemsCount > 1) - { - return false; - } - - var fileExplorerSelectedItemPath = selectedItems.GetItemAt(0).ToIFileSystemItem().Path; - var currentItemPath = ViewModel.CurrentItem?.Path; - return fileExplorerSelectedItemPath != null && currentItemPath != null && fileExplorerSelectedItemPath != currentItemPath; + return selectedItem.Matches(ViewModel.CurrentItem?.Path); } catch (Exception ex) { diff --git a/src/modules/peek/Peek.UITests/PeekFilePreviewTests.cs b/src/modules/peek/Peek.UITests/PeekFilePreviewTests.cs index 36f2491fcf..fd57c444ca 100644 --- a/src/modules/peek/Peek.UITests/PeekFilePreviewTests.cs +++ b/src/modules/peek/Peek.UITests/PeekFilePreviewTests.cs @@ -9,6 +9,7 @@ using System.Drawing; using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.PowerToys.UITest; @@ -35,6 +36,105 @@ public class PeekFilePreviewTests : UITestBase { } + static PeekFilePreviewTests() + { + FixSettingsFileBeforeTests(); + } + + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; + + private static void FixSettingsFileBeforeTests() + { + try + { + // Default Peek settings + string peekSettingsContent = @"{ + ""name"": ""Peek"", + ""version"": ""1.0"", + ""properties"": { + ""ActivationShortcut"": { + ""win"": false, + ""ctrl"": true, + ""alt"": false, + ""shift"": false, + ""code"": 32, + ""key"": ""Space"" + }, + ""AlwaysRunNotElevated"": { + ""value"": true + }, + ""CloseAfterLosingFocus"": { + ""value"": false + }, + ""ConfirmFileDelete"": { + ""value"": true + }, + ""EnableSpaceToActivate"": { + ""value"": false + } + } + }"; + + // Update Peek module settings + SettingsConfigHelper.UpdateModuleSettings( + "Peek", + peekSettingsContent, + (settings) => + { + // Get or ensure properties section exists + Dictionary properties; + + if (settings.TryGetValue("properties", out var propertiesObj)) + { + if (propertiesObj is Dictionary dict) + { + properties = dict; + } + else if (propertiesObj is JsonElement jsonElem) + { + properties = JsonSerializer.Deserialize>(jsonElem.GetRawText()) + ?? throw new InvalidOperationException("Failed to deserialize properties"); + } + else + { + properties = new Dictionary(); + } + } + else + { + properties = new Dictionary(); + } + + // Update the required properties + properties["ActivationShortcut"] = new Dictionary + { + { "win", false }, + { "ctrl", true }, + { "alt", false }, + { "shift", false }, + { "code", 32 }, + { "key", "Space" }, + }; + + properties["EnableSpaceToActivate"] = new Dictionary + { + { "value", false }, + }; + + settings["properties"] = properties; + }); + + // Disable all modules except Peek in global settings + SettingsConfigHelper.ConfigureGlobalModuleSettings("Peek"); + + Debug.WriteLine("Successfully updated all settings - Peek shortcut configured and all modules except Peek disabled"); + } + catch (Exception ex) + { + Assert.Fail($"ERROR in FixSettingsFileBeforeTests: {ex.Message}"); + } + } + [TestInitialize] public void TestInitialize() { diff --git a/src/modules/poweraccent/PowerAccent.Core/Languages.cs b/src/modules/poweraccent/PowerAccent.Core/Languages.cs index 6e329ffe7f..06c3a2bea3 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Languages.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Languages.cs @@ -212,7 +212,7 @@ namespace PowerAccent.Core LetterKey.VK_L => new[] { "ļ", "₺" }, // ₺ is in VK_T for other languages, but not VK_L, so we add it here. LetterKey.VK_M => new[] { "ṁ" }, LetterKey.VK_N => new[] { "ņ", "ṅ", "ⁿ", "ℕ", "№" }, - LetterKey.VK_O => new[] { "ȯ", "∅" }, + LetterKey.VK_O => new[] { "ȯ", "∅", "⌀" }, LetterKey.VK_P => new[] { "ṗ", "℗", "∏", "¶" }, LetterKey.VK_Q => new[] { "ℚ" }, LetterKey.VK_R => new[] { "ṙ", "®", "ℝ" }, diff --git a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj index 3afb41f546..16272dba69 100644 --- a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj +++ b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj @@ -44,6 +44,7 @@ true NotUsing /fsanitize=address /fsanitize-coverage=inline-8bit-counters /fsanitize-coverage=edge /fsanitize-coverage=trace-cmp /fsanitize-coverage=trace-div %(AdditionalOptions) + MultiThreaded stdcpplatest ..\;..\lib\;..\..\..\;%(AdditionalIncludeDirectories) @@ -71,6 +72,7 @@ Console + windowscodecs.lib;%(AdditionalDependencies) diff --git a/src/runner/general_settings.cpp b/src/runner/general_settings.cpp index 50dd8dbbc8..9f8ceeb8ad 100644 --- a/src/runner/general_settings.cpp +++ b/src/runner/general_settings.cpp @@ -35,6 +35,31 @@ namespace ensure_ignored_conflict_properties_shape(obj); return obj; } + + DashboardSortOrder parse_dashboard_sort_order(const json::JsonObject& obj, DashboardSortOrder fallback) + { + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::Number)) + { + const auto raw_value = static_cast(obj.GetNamedNumber(L"dashboard_sort_order", static_cast(static_cast(fallback)))); + return raw_value == static_cast(DashboardSortOrder::ByStatus) ? DashboardSortOrder::ByStatus : DashboardSortOrder::Alphabetical; + } + + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::String)) + { + const auto raw = obj.GetNamedString(L"dashboard_sort_order"); + if (raw == L"ByStatus") + { + return DashboardSortOrder::ByStatus; + } + + if (raw == L"Alphabetical") + { + return DashboardSortOrder::Alphabetical; + } + } + + return fallback; + } } // TODO: would be nice to get rid of these globals, since they're basically cached json settings @@ -46,6 +71,7 @@ static bool download_updates_automatically = true; static bool show_whats_new_after_updates = true; static bool enable_experimentation = true; static bool enable_warnings_elevated_apps = true; +static DashboardSortOrder dashboard_sort_order = DashboardSortOrder::Alphabetical; static json::JsonObject ignored_conflict_properties = create_default_ignored_conflict_properties(); json::JsonObject GeneralSettings::to_json() @@ -75,6 +101,7 @@ json::JsonObject GeneralSettings::to_json() result.SetNamedValue(L"download_updates_automatically", json::value(downloadUpdatesAutomatically)); result.SetNamedValue(L"show_whats_new_after_updates", json::value(showWhatsNewAfterUpdates)); result.SetNamedValue(L"enable_experimentation", json::value(enableExperimentation)); + result.SetNamedValue(L"dashboard_sort_order", json::value(static_cast(dashboardSortOrder))); result.SetNamedValue(L"is_admin", json::value(isAdmin)); result.SetNamedValue(L"enable_warnings_elevated_apps", json::value(enableWarningsElevatedApps)); result.SetNamedValue(L"theme", json::value(theme)); @@ -99,6 +126,7 @@ json::JsonObject load_general_settings() show_whats_new_after_updates = loaded.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = loaded.GetNamedBoolean(L"enable_experimentation", true); enable_warnings_elevated_apps = loaded.GetNamedBoolean(L"enable_warnings_elevated_apps", true); + dashboard_sort_order = parse_dashboard_sort_order(loaded, dashboard_sort_order); if (json::has(loaded, L"ignored_conflict_properties", json::JsonValueType::Object)) { @@ -128,6 +156,7 @@ GeneralSettings get_general_settings() .downloadUpdatesAutomatically = download_updates_automatically && is_user_admin, .showWhatsNewAfterUpdates = show_whats_new_after_updates, .enableExperimentation = enable_experimentation, + .dashboardSortOrder = dashboard_sort_order, .theme = settings_theme, .systemTheme = WindowsColors::is_dark_mode() ? L"dark" : L"light", .powerToysVersion = get_product_version(), @@ -159,6 +188,7 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) show_whats_new_after_updates = general_configs.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = general_configs.GetNamedBoolean(L"enable_experimentation", true); + dashboard_sort_order = parse_dashboard_sort_order(general_configs, dashboard_sort_order); // apply_general_settings is called by the runner's WinMain, so we can just force the run at startup gpo rule here. auto gpo_run_as_startup = powertoys_gpo::getConfiguredRunAtStartupValue(); diff --git a/src/runner/general_settings.h b/src/runner/general_settings.h index 38fbd5789a..b4f7638846 100644 --- a/src/runner/general_settings.h +++ b/src/runner/general_settings.h @@ -2,6 +2,12 @@ #include +enum class DashboardSortOrder +{ + Alphabetical = 0, + ByStatus = 1, +}; + struct GeneralSettings { bool isStartupEnabled; @@ -16,6 +22,7 @@ struct GeneralSettings bool downloadUpdatesAutomatically; bool showWhatsNewAfterUpdates; bool enableExperimentation; + DashboardSortOrder dashboardSortOrder; std::wstring theme; std::wstring systemTheme; std::wstring powerToysVersion; diff --git a/src/runner/main.cpp b/src/runner/main.cpp index 4b29149f78..c20293f9ed 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -161,6 +161,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.MouseJump.dll", L"PowerToys.AlwaysOnTopModuleInterface.dll", L"PowerToys.MousePointerCrosshairs.dll", + L"PowerToys.CursorWrap.dll", L"PowerToys.PowerAccentModuleInterface.dll", L"PowerToys.PowerOCRModuleInterface.dll", L"PowerToys.AdvancedPasteModuleInterface.dll", diff --git a/src/runner/runner.vcxproj b/src/runner/runner.vcxproj index e18979a76c..b9024524b7 100644 --- a/src/runner/runner.vcxproj +++ b/src/runner/runner.vcxproj @@ -28,6 +28,7 @@ v143 None true + true diff --git a/src/settings-ui/Settings.UI.Library/AIServiceType.cs b/src/settings-ui/Settings.UI.Library/AIServiceType.cs new file mode 100644 index 0000000000..27eccff1cf --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceType.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Supported AI service types for PowerToys AI experiences. + /// + public enum AIServiceType + { + Unknown = 0, + OpenAI, + AzureOpenAI, + Onnx, + ML, + FoundryLocal, + Mistral, + Google, + AzureAIInference, + Ollama, + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs new file mode 100644 index 0000000000..5b19212eba --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public static class AIServiceTypeExtensions + { + /// + /// Convert a persisted string value into an . + /// Supports historical casing and aliases. + /// + public static AIServiceType ToAIServiceType(this string serviceType) + { + if (string.IsNullOrWhiteSpace(serviceType)) + { + return AIServiceType.OpenAI; + } + + var normalized = serviceType.Trim().ToLowerInvariant(); + return normalized switch + { + "openai" => AIServiceType.OpenAI, + "azureopenai" or "azure" => AIServiceType.AzureOpenAI, + "onnx" => AIServiceType.Onnx, + "foundrylocal" or "foundry" or "fl" => AIServiceType.FoundryLocal, + "ml" or "windowsml" or "winml" => AIServiceType.ML, + "mistral" => AIServiceType.Mistral, + "google" or "googleai" or "googlegemini" => AIServiceType.Google, + "azureaiinference" or "azureinference" => AIServiceType.AzureAIInference, + "ollama" => AIServiceType.Ollama, + _ => AIServiceType.Unknown, + }; + } + + /// + /// Convert an to the canonical string used for persistence. + /// + public static string ToConfigurationString(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "OpenAI", + AIServiceType.AzureOpenAI => "AzureOpenAI", + AIServiceType.Onnx => "Onnx", + AIServiceType.FoundryLocal => "FoundryLocal", + AIServiceType.ML => "ML", + AIServiceType.Mistral => "Mistral", + AIServiceType.Google => "Google", + AIServiceType.AzureAIInference => "AzureAIInference", + AIServiceType.Ollama => "Ollama", + AIServiceType.Unknown => string.Empty, + _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, "Unsupported AI service type."), + }; + } + + /// + /// Convert an into the normalized key used internally. + /// + public static string ToNormalizedKey(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "openai", + AIServiceType.AzureOpenAI => "azureopenai", + AIServiceType.Onnx => "onnx", + AIServiceType.FoundryLocal => "foundrylocal", + AIServiceType.ML => "ml", + AIServiceType.Mistral => "mistral", + AIServiceType.Google => "google", + AIServiceType.AzureAIInference => "azureaiinference", + AIServiceType.Ollama => "ollama", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs new file mode 100644 index 0000000000..df01b1816a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Metadata information for an AI service type. + /// + public class AIServiceTypeMetadata + { + public AIServiceType ServiceType { get; init; } + + public string DisplayName { get; init; } + + public string IconPath { get; init; } + + public bool IsOnlineService { get; init; } + + public bool IsAvailableInUI { get; init; } = true; + + public bool IsLocalModel { get; init; } + + public string LegalDescription { get; init; } + + public string TermsLabel { get; init; } + + public Uri TermsUri { get; init; } + + public string PrivacyLabel { get; init; } + + public Uri PrivacyUri { get; init; } + + public bool HasLegalInfo => !string.IsNullOrWhiteSpace(LegalDescription); + + public bool HasTermsLink => TermsUri is not null && !string.IsNullOrEmpty(TermsLabel); + + public bool HasPrivacyLink => PrivacyUri is not null && !string.IsNullOrEmpty(PrivacyLabel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs new file mode 100644 index 0000000000..653b85553e --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library; + +/// +/// Centralized registry for AI service type metadata. +/// +public static class AIServiceTypeRegistry +{ + private static readonly Dictionary MetadataMap = new() + { + [AIServiceType.AzureAIInference] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureAIInference, + DisplayName = "Azure AI Inference", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Azure.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureAIInference_LegalDescription", + TermsLabel = "AdvancedPaste_AzureAIInference_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureAIInference_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.AzureOpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureOpenAI, + DisplayName = "Azure OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/AzureAI.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureOpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_AzureOpenAI_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureOpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.FoundryLocal] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.FoundryLocal, + DisplayName = "Foundry Local", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg", + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_FoundryLocal_LegalDescription", // Resource key for localized description + }, + [AIServiceType.Google] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Google, + DisplayName = "Google", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Gemini.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Google_LegalDescription", + TermsLabel = "AdvancedPaste_Google_TermsLabel", + TermsUri = new Uri("https://ai.google.dev/gemini-api/terms"), + PrivacyLabel = "AdvancedPaste_Google_PrivacyLabel", + PrivacyUri = new Uri("https://support.google.com/gemini/answer/13594961"), + }, + [AIServiceType.Mistral] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Mistral, + DisplayName = "Mistral", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Mistral.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Mistral_LegalDescription", + TermsLabel = "AdvancedPaste_Mistral_TermsLabel", + TermsUri = new Uri("https://mistral.ai/terms-of-service/"), + PrivacyLabel = "AdvancedPaste_Mistral_PrivacyLabel", + PrivacyUri = new Uri("https://mistral.ai/privacy-policy/"), + }, + [AIServiceType.ML] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.ML, + DisplayName = "Windows ML", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/WindowsML.svg", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IsAvailableInUI = false, + IsOnlineService = false, + IsLocalModel = true, + }, + [AIServiceType.Ollama] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Ollama, + DisplayName = "Ollama", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Ollama.svg", + + // Ollama provide online service, but we treat it as local model at first version since it can is known for local model. + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + TermsLabel = "AdvancedPaste_Ollama_TermsLabel", + TermsUri = new Uri("https://ollama.org/terms"), + PrivacyLabel = "AdvancedPaste_Ollama_PrivacyLabel", + PrivacyUri = new Uri("https://ollama.org/privacy"), + }, + [AIServiceType.Onnx] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Onnx, + DisplayName = "ONNX", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Onnx.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + [AIServiceType.OpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.OpenAI, + DisplayName = "OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_OpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_OpenAI_TermsLabel", + TermsUri = new Uri("https://openai.com/terms"), + PrivacyLabel = "AdvancedPaste_OpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://openai.com/privacy"), + }, + [AIServiceType.Unknown] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Unknown, + DisplayName = "Unknown", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + }; + + /// + /// Get metadata for a specific service type. + /// + public static AIServiceTypeMetadata GetMetadata(AIServiceType serviceType) + { + return MetadataMap.TryGetValue(serviceType, out var metadata) + ? metadata + : MetadataMap[AIServiceType.Unknown]; + } + + /// + /// Get metadata for a service type from its string representation. + /// + public static AIServiceTypeMetadata GetMetadata(string serviceType) + { + var type = serviceType.ToAIServiceType(); + return GetMetadata(type); + } + + /// + /// Get icon path for a service type. + /// + public static string GetIconPath(AIServiceType serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// + /// Get icon path for a service type from its string representation. + /// + public static string GetIconPath(string serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// + /// Get all service types available in the UI. + /// + public static IEnumerable GetAvailableServiceTypes() + { + return MetadataMap.Values.Where(m => m.IsAvailableInUI); + } + + /// + /// Get all online service types available in the UI. + /// + public static IEnumerable GetOnlineServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsOnlineService); + } + + /// + /// Get all local service types available in the UI. + /// + public static IEnumerable GetLocalServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsLocalModel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index 43baf89351..c981295906 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -14,6 +14,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { private int _id; private string _name = string.Empty; + private string _description = string.Empty; private string _prompt = string.Empty; private HotkeySettings _shortcut = new(); private bool _isShown; @@ -43,6 +44,13 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction } } + [JsonPropertyName("description")] + public string Description + { + get => _description; + set => Set(ref _description, value ?? string.Empty); + } + [JsonPropertyName("prompt")] public string Prompt { @@ -128,6 +136,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { Id = other.Id; Name = other.Name; + Description = other.Description; Prompt = other.Prompt; Shortcut = other.GetShortcutClone(); IsShown = other.IsShown; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs new file mode 100644 index 0000000000..b61f83dd3d --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Helper methods for migrating legacy Advanced Paste settings to the updated schema. + /// + public static class AdvancedPasteMigrationHelper + { + /// + /// Ensures an OpenAI provider exists in the configuration, creating one if necessary. + /// + /// The configuration instance. + /// The ensured provider and a flag indicating whether changes were made. + public static (PasteAIProviderDefinition Provider, bool Updated) EnsureOpenAIProvider(PasteAIConfiguration configuration) + { + if (configuration is null) + { + return (null, false); + } + + configuration.Providers ??= new ObservableCollection(); + + const string serviceTypeKey = "OpenAI"; + var existingProvider = configuration.Providers.FirstOrDefault(provider => string.Equals(provider.ServiceType, serviceTypeKey, StringComparison.OrdinalIgnoreCase)); + bool updated = false; + + if (existingProvider is null) + { + existingProvider = CreateProvider(serviceTypeKey); + configuration.Providers.Add(existingProvider); + updated = true; + } + + updated |= EnsureActiveProviderIsValid(configuration, existingProvider); + + return (existingProvider, updated); + } + + /// + /// Creates a provider with default values for the requested service type. + /// + private static PasteAIProviderDefinition CreateProvider(string serviceTypeKey) + { + var serviceType = serviceTypeKey.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + var provider = new PasteAIProviderDefinition + { + ServiceType = serviceTypeKey, + ModelName = PasteAIProviderDefaults.GetDefaultModelName(serviceType), + EndpointUrl = string.Empty, + ApiVersion = string.Empty, + DeploymentName = string.Empty, + ModelPath = string.Empty, + SystemPrompt = string.Empty, + ModerationEnabled = serviceType == AIServiceType.OpenAI, + IsLocalModel = metadata.IsLocalModel, + }; + + return provider; + } + + private static bool EnsureActiveProviderIsValid(PasteAIConfiguration configuration, PasteAIProviderDefinition preferredProvider = null) + { + if (configuration?.Providers is null || configuration.Providers.Count == 0) + { + if (!string.IsNullOrWhiteSpace(configuration?.ActiveProviderId)) + { + configuration.ActiveProviderId = string.Empty; + return true; + } + + return false; + } + + bool updated = false; + + var activeProvider = configuration.Providers.FirstOrDefault(provider => string.Equals(provider.Id, configuration.ActiveProviderId, StringComparison.OrdinalIgnoreCase)); + if (activeProvider is null) + { + activeProvider = preferredProvider ?? configuration.Providers.First(); + configuration.ActiveProviderId = activeProvider.Id; + updated = true; + } + + foreach (var provider in configuration.Providers) + { + bool shouldBeActive = string.Equals(provider.Id, configuration.ActiveProviderId, StringComparison.OrdinalIgnoreCase); + if (provider.IsActive != shouldBeActive) + { + provider.IsActive = shouldBeActive; + updated = true; + } + } + + return updated; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs index d40bd686d3..e3f6081266 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; @@ -23,13 +24,51 @@ namespace Microsoft.PowerToys.Settings.UI.Library PasteAsJsonShortcut = new(); CustomActions = new(); AdditionalActions = new(); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + EnableClipboardPreview = true; + PasteAIConfiguration = new(); } [JsonConverter(typeof(BoolPropertyJsonConverter))] - public bool IsAdvancedAIEnabled { get; set; } + public bool IsAIEnabled { get; set; } + + private bool? _legacyAdvancedAIEnabled; + + [JsonPropertyName("IsAdvancedAIEnabled")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public BoolProperty LegacyAdvancedAIEnabledProperty + { + get => null; + set + { + if (value is not null) + { + LegacyAdvancedAIEnabled = value.Value; + } + } + } + + [JsonIgnore] + public bool? LegacyAdvancedAIEnabled + { + get => _legacyAdvancedAIEnabled; + private set => _legacyAdvancedAIEnabled = value; + } + + public bool TryConsumeLegacyAdvancedAIEnabled(out bool value) + { + if (_legacyAdvancedAIEnabled is bool flag) + { + value = flag; + _legacyAdvancedAIEnabled = null; + return true; + } + + value = default; + return false; + } [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool ShowCustomPreview { get; set; } @@ -37,6 +76,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool CloseAfterLosingFocus { get; set; } + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool EnableClipboardPreview { get; set; } + [JsonPropertyName("advanced-paste-ui-hotkey")] public HotkeySettings AdvancedPasteUIShortcut { get; set; } @@ -57,6 +99,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library [CmdConfigureIgnoreAttribute] public AdvancedPasteAdditionalActions AdditionalActions { get; init; } + [JsonPropertyName("paste-ai-configuration")] + [CmdConfigureIgnoreAttribute] + public PasteAIConfiguration PasteAIConfiguration { get; set; } + public override string ToString() => JsonSerializer.Serialize(this); } diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs new file mode 100644 index 0000000000..cf66b4ba09 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +using Settings.UI.Library.Attributes; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapProperties + { + [CmdConfigureIgnore] + public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, false, true, false, 0x55); // Win + Alt + U + + [JsonPropertyName("activation_shortcut")] + public HotkeySettings ActivationShortcut { get; set; } + + [JsonPropertyName("auto_activate")] + public BoolProperty AutoActivate { get; set; } + + [JsonPropertyName("disable_wrap_during_drag")] + public BoolProperty DisableWrapDuringDrag { get; set; } + + public CursorWrapProperties() + { + ActivationShortcut = DefaultActivationShortcut; + AutoActivate = new BoolProperty(false); + DisableWrapDuringDrag = new BoolProperty(true); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs new file mode 100644 index 0000000000..8c9059123c --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig + { + public const string ModuleName = "CursorWrap"; + + [JsonPropertyName("properties")] + public CursorWrapProperties Properties { get; set; } + + public CursorWrapSettings() + { + Name = ModuleName; + Properties = new CursorWrapProperties(); + Version = "1.0"; + } + + public string GetModuleName() + { + return Name; + } + + public ModuleType GetModuleType() => ModuleType.CursorWrap; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_CursorWrap_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + // This can be utilized in the future if the settings.json file is to be modified/deleted. + public bool UpgradeSettingsConfiguration() + { + return false; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index 977c03b839..d7100d9ae4 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -513,6 +513,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool cursorWrap; // defaulting to off + + [JsonPropertyName("CursorWrap")] + public bool CursorWrap + { + get => cursorWrap; + set + { + if (cursorWrap != value) + { + LogTelemetryEvent(value); + cursorWrap = value; + } + } + } + private bool lightSwitch; [JsonPropertyName("LightSwitch")] diff --git a/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.cs b/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.cs new file mode 100644 index 0000000000..782bcccf48 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Settings.UI.Library.Enumerations +{ + public enum HostsDeleteBackupMode + { + Never = 0, + Count = 1, + Age = 2, + } +} diff --git a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs index 24ff4584fe..0f380aca78 100644 --- a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs +++ b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs @@ -13,6 +13,12 @@ using Settings.UI.Library.Attributes; namespace Microsoft.PowerToys.Settings.UI.Library { + public enum DashboardSortOrder + { + Alphabetical, + ByStatus, + } + public class GeneralSettings : ISettingsConfig { // Gets or sets a value indicating whether run powertoys on start-up. @@ -76,6 +82,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("enable_experimentation")] public bool EnableExperimentation { get; set; } + [JsonPropertyName("dashboard_sort_order")] + public DashboardSortOrder DashboardSortOrder { get; set; } + [JsonPropertyName("ignored_conflict_properties")] public ShortcutConflictProperties IgnoredConflictProperties { get; set; } @@ -89,6 +98,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library ShowNewUpdatesToastNotification = true; AutoDownloadUpdates = false; EnableExperimentation = true; + DashboardSortOrder = DashboardSortOrder.Alphabetical; Theme = "system"; SystemTheme = "light"; try diff --git a/src/settings-ui/Settings.UI.Library/HostsProperties.cs b/src/settings-ui/Settings.UI.Library/HostsProperties.cs index 6ec9924049..b4feedce45 100644 --- a/src/settings-ui/Settings.UI.Library/HostsProperties.cs +++ b/src/settings-ui/Settings.UI.Library/HostsProperties.cs @@ -1,10 +1,10 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; +using System.IO; using System.Text.Json.Serialization; - -using Settings.UI.Library.Attributes; using Settings.UI.Library.Enumerations; namespace Microsoft.PowerToys.Settings.UI.Library @@ -27,6 +27,17 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool NoLeadingSpaces { get; set; } + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool BackupHosts { get; set; } + + public string BackupPath { get; set; } + + public HostsDeleteBackupMode DeleteBackupsMode { get; set; } + + public int DeleteBackupsDays { get; set; } + + public int DeleteBackupsCount { get; set; } + public HostsProperties() { ShowStartupWarning = true; @@ -35,6 +46,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library AdditionalLinesPosition = HostsAdditionalLinesPosition.Top; Encoding = HostsEncoding.Utf8; NoLeadingSpaces = false; + BackupHosts = true; + BackupPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"System32\drivers\etc"); + DeleteBackupsMode = HostsDeleteBackupMode.Age; + DeleteBackupsDays = 15; + DeleteBackupsCount = 5; } } } diff --git a/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs new file mode 100644 index 0000000000..3308ab535e --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Configuration for Paste AI features (custom action transformations like custom prompt processing) + /// + public class PasteAIConfiguration : INotifyPropertyChanged + { + private string _activeProviderId = string.Empty; + private ObservableCollection _providers = new(); + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("active-provider-id")] + public string ActiveProviderId + { + get => _activeProviderId; + set => SetProperty(ref _activeProviderId, value ?? string.Empty); + } + + [JsonPropertyName("providers")] + public ObservableCollection Providers + { + get => _providers; + set => SetProperty(ref _providers, value ?? new ObservableCollection()); + } + + [JsonIgnore] + public PasteAIProviderDefinition ActiveProvider + { + get + { + if (_providers is null || _providers.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(_activeProviderId)) + { + var match = _providers.FirstOrDefault(provider => string.Equals(provider.Id, _activeProviderId, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + return match; + } + } + + return _providers[0]; + } + } + + [JsonIgnore] + public AIServiceType ActiveServiceTypeKind => ActiveProvider?.ServiceTypeKind ?? AIServiceType.OpenAI; + + public override string ToString() + => JsonSerializer.Serialize(this); + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs new file mode 100644 index 0000000000..1ccfa753fa --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Provides default values for Paste AI provider definitions. + /// + public static class PasteAIProviderDefaults + { + /// + /// Gets the default model name for a given AI service type. + /// + public static string GetDefaultModelName(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "gpt-4o", + AIServiceType.AzureOpenAI => "gpt-4o", + AIServiceType.Mistral => "mistral-large-latest", + AIServiceType.Google => "gemini-1.5-pro", + AIServiceType.AzureAIInference => "gpt-4o-mini", + AIServiceType.Ollama => "llama3", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs new file mode 100644 index 0000000000..0fbb3328e7 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Represents a single Paste AI provider configuration entry. + /// + public class PasteAIProviderDefinition : INotifyPropertyChanged + { + private string _id = Guid.NewGuid().ToString("N"); + private string _serviceType = "OpenAI"; + private string _modelName = string.Empty; + private string _endpointUrl = string.Empty; + private string _apiVersion = string.Empty; + private string _deploymentName = string.Empty; + private string _modelPath = string.Empty; + private string _systemPrompt = string.Empty; + private bool _moderationEnabled = true; + private bool _isActive; + private bool _enableAdvancedAI; + private bool _isLocalModel; + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("id")] + public string Id + { + get => _id; + set => SetProperty(ref _id, value); + } + + [JsonPropertyName("service-type")] + public string ServiceType + { + get => _serviceType; + set + { + if (SetProperty(ref _serviceType, string.IsNullOrWhiteSpace(value) ? "OpenAI" : value)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonIgnore] + public AIServiceType ServiceTypeKind + { + get => ServiceType.ToAIServiceType(); + set => ServiceType = value.ToConfigurationString(); + } + + [JsonPropertyName("model-name")] + public string ModelName + { + get => _modelName; + set + { + if (SetProperty(ref _modelName, value ?? string.Empty)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonPropertyName("endpoint-url")] + public string EndpointUrl + { + get => _endpointUrl; + set => SetProperty(ref _endpointUrl, value ?? string.Empty); + } + + [JsonPropertyName("api-version")] + public string ApiVersion + { + get => _apiVersion; + set => SetProperty(ref _apiVersion, value ?? string.Empty); + } + + [JsonPropertyName("deployment-name")] + public string DeploymentName + { + get => _deploymentName; + set => SetProperty(ref _deploymentName, value ?? string.Empty); + } + + [JsonPropertyName("model-path")] + public string ModelPath + { + get => _modelPath; + set => SetProperty(ref _modelPath, value ?? string.Empty); + } + + [JsonPropertyName("system-prompt")] + public string SystemPrompt + { + get => _systemPrompt; + set => SetProperty(ref _systemPrompt, value?.Trim() ?? string.Empty); + } + + [JsonPropertyName("moderation-enabled")] + public bool ModerationEnabled + { + get => _moderationEnabled; + set => SetProperty(ref _moderationEnabled, value); + } + + [JsonPropertyName("enable-advanced-ai")] + public bool EnableAdvancedAI + { + get => _enableAdvancedAI; + set => SetProperty(ref _enableAdvancedAI, value); + } + + [JsonPropertyName("is-local-model")] + public bool IsLocalModel + { + get => _isLocalModel; + set => SetProperty(ref _isLocalModel, value); + } + + [JsonIgnore] + public bool IsActive + { + get => _isActive; + set => SetProperty(ref _isActive, value); + } + + [JsonIgnore] + public string DisplayName => string.IsNullOrWhiteSpace(ModelName) ? ServiceType : ModelName; + + public PasteAIProviderDefinition Clone() + { + return new PasteAIProviderDefinition + { + Id = Id, + ServiceType = ServiceType, + ModelName = ModelName, + EndpointUrl = EndpointUrl, + ApiVersion = ApiVersion, + DeploymentName = DeploymentName, + ModelPath = ModelPath, + SystemPrompt = SystemPrompt, + ModerationEnabled = ModerationEnabled, + EnableAdvancedAI = EnableAdvancedAI, + IsLocalModel = IsLocalModel, + IsActive = IsActive, + }; + } + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs new file mode 100644 index 0000000000..3d6d781d03 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class SndCursorWrapSettings + { + [JsonPropertyName("CursorWrap")] + public CursorWrapSettings CursorWrap { get; set; } + + public SndCursorWrapSettings() + { + } + + public SndCursorWrapSettings(CursorWrapSettings settings) + { + CursorWrap = settings; + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs index 9325907a72..1bca1b573a 100644 --- a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs @@ -87,6 +87,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library public IntProperty RecordScaling { get; set; } + public StringProperty RecordFormat { get; set; } + public BoolProperty CaptureAudio { get; set; } public StringProperty MicrophoneDeviceId { get; set; } diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png new file mode 100644 index 0000000000..c32f1e309a Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png index 6cdb55cb66..581c317518 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg new file mode 100644 index 0000000000..7497187ad7 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg new file mode 100644 index 0000000000..e6fd7121b2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg new file mode 100644 index 0000000000..53747d557d --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg new file mode 100644 index 0000000000..56a5fe461b --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg new file mode 100644 index 0000000000..ce2471552e --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg new file mode 100644 index 0000000000..e44dda654d --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg new file mode 100644 index 0000000000..301a40fd55 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg new file mode 100644 index 0000000000..87aacb3a4f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg new file mode 100644 index 0000000000..f72a3c64d1 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg new file mode 100644 index 0000000000..fafc16b59f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png index 7e9a2da1a8..69ed506e99 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png index 15568110cc..14d5e71d53 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png index 83d1fbc553..99a8f64ed4 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Background.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Background.png new file mode 100644 index 0000000000..929a637d34 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Background.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Hero.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Hero.png new file mode 100644 index 0000000000..a8889dcc05 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal_Hero.png differ diff --git a/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs b/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs new file mode 100644 index 0000000000..27689435cc --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class EnumToBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value == null || parameter == null) + { + return false; + } + + // Get the enum value as string + var enumString = value.ToString(); + var parameterString = parameter.ToString(); + + return enumString.Equals(parameterString, StringComparison.OrdinalIgnoreCase); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs new file mode 100644 index 0000000000..7d632906c2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public partial class ServiceTypeToIconConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string serviceType || string.IsNullOrWhiteSpace(serviceType)) + { + return new ImageIcon { Source = new SvgImageSource(new Uri(AIServiceTypeRegistry.GetIconPath(AIServiceType.OpenAI))) }; + } + + var iconPath = AIServiceTypeRegistry.GetIconPath(serviceType); + return new ImageIcon { Source = new SvgImageSource(new Uri(iconPath)) }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/src/settings-ui/Settings.UI/Converters/StringToDouble.cs b/src/settings-ui/Settings.UI/Converters/StringToDouble.cs new file mode 100644 index 0000000000..fae0618467 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/StringToDouble.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class StringToDoubleConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double result)) + { + return result; + } + + return 0.0; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is double d) + { + return d.ToString(CultureInfo.InvariantCulture); + } + + return "0"; + } + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs index b33ac264ed..89b22d8164 100644 --- a/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs @@ -22,7 +22,8 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.FindMyMouse: case ModuleType.MouseHighlighter: case ModuleType.MouseJump: - case ModuleType.MousePointerCrosshairs: return $"MouseUtils_{moduleType}/Header"; + case ModuleType.MousePointerCrosshairs: + case ModuleType.CursorWrap: return $"MouseUtils_{moduleType}/Header"; default: return $"{moduleType}/ModuleTitle"; } } @@ -52,6 +53,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.CursorWrap: return generalSettingsConfig.Enabled.CursorWrap; case ModuleType.LightSwitch: return generalSettingsConfig.Enabled.LightSwitch; case ModuleType.EnvironmentVariables: return generalSettingsConfig.Enabled.EnvironmentVariables; case ModuleType.FancyZones: return generalSettingsConfig.Enabled.FancyZones; @@ -89,6 +91,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.CursorWrap: generalSettingsConfig.Enabled.CursorWrap = isEnabled; break; case ModuleType.LightSwitch: generalSettingsConfig.Enabled.LightSwitch = isEnabled; break; case ModuleType.EnvironmentVariables: generalSettingsConfig.Enabled.EnvironmentVariables = isEnabled; break; case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break; @@ -125,6 +128,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.CmdPal: return GPOWrapper.GetConfiguredCmdPalEnabledValue(); case ModuleType.ColorPicker: return GPOWrapper.GetConfiguredColorPickerEnabledValue(); case ModuleType.CropAndLock: return GPOWrapper.GetConfiguredCropAndLockEnabledValue(); + case ModuleType.CursorWrap: return GPOWrapper.GetConfiguredCursorWrapEnabledValue(); case ModuleType.EnvironmentVariables: return GPOWrapper.GetConfiguredEnvironmentVariablesEnabledValue(); case ModuleType.FancyZones: return GPOWrapper.GetConfiguredFancyZonesEnabledValue(); case ModuleType.FileLocksmith: return GPOWrapper.GetConfiguredFileLocksmithEnabledValue(); @@ -161,6 +165,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers ModuleType.CmdPal => typeof(CmdPalPage), ModuleType.ColorPicker => typeof(ColorPickerPage), ModuleType.CropAndLock => typeof(CropAndLockPage), + ModuleType.CursorWrap => typeof(MouseUtilsPage), ModuleType.LightSwitch => typeof(LightSwitchPage), ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage), ModuleType.FancyZones => typeof(FancyZonesPage), diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index dd70af7533..a6751adb98 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -20,8 +20,16 @@ PowerToys.Settings.pri + + + + + + + + @@ -53,11 +61,19 @@ + + + + PreserveNewest + + + + @@ -105,6 +121,7 @@ + @@ -197,4 +214,4 @@ - \ No newline at end of file + 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 b071e7f6fe..44470ebbc1 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml @@ -9,7 +9,10 @@ mc:Ignorable="d"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs new file mode 100644 index 0000000000..400074f9d3 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs @@ -0,0 +1,447 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using LanguageModelProvider; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public sealed partial class FoundryLocalModelPicker : UserControl +{ + private INotifyCollectionChanged _cachedModelsSubscription; + private INotifyCollectionChanged _downloadableModelsSubscription; + private bool _suppressSelection; + + public FoundryLocalModelPicker() + { + InitializeComponent(); + Loaded += (_, _) => UpdateVisualStates(); + } + + public delegate void ModelSelectionChangedEventHandler(object sender, ModelDetails model); + + public delegate void DownloadRequestedEventHandler(object sender, object payload); + + public delegate void LoadRequestedEventHandler(object sender); + + public event ModelSelectionChangedEventHandler SelectionChanged; + + public event LoadRequestedEventHandler LoadRequested; + + public IEnumerable CachedModels + { + get => (IEnumerable)GetValue(CachedModelsProperty); + set => SetValue(CachedModelsProperty, value); + } + + public static readonly DependencyProperty CachedModelsProperty = + DependencyProperty.Register(nameof(CachedModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnCachedModelsChanged)); + + public IEnumerable DownloadableModels + { + get => (IEnumerable)GetValue(DownloadableModelsProperty); + set => SetValue(DownloadableModelsProperty, value); + } + + public static readonly DependencyProperty DownloadableModelsProperty = + DependencyProperty.Register(nameof(DownloadableModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnDownloadableModelsChanged)); + + public ModelDetails SelectedModel + { + get => (ModelDetails)GetValue(SelectedModelProperty); + set => SetValue(SelectedModelProperty, value); + } + + public static readonly DependencyProperty SelectedModelProperty = + DependencyProperty.Register(nameof(SelectedModel), typeof(ModelDetails), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnSelectedModelChanged)); + + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public static readonly DependencyProperty IsLoadingProperty = + DependencyProperty.Register(nameof(IsLoading), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public bool IsAvailable + { + get => (bool)GetValue(IsAvailableProperty); + set => SetValue(IsAvailableProperty, value); + } + + public static readonly DependencyProperty IsAvailableProperty = + DependencyProperty.Register(nameof(IsAvailable), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public string StatusText + { + get => (string)GetValue(StatusTextProperty); + set => SetValue(StatusTextProperty, value); + } + + public static readonly DependencyProperty StatusTextProperty = + DependencyProperty.Register(nameof(StatusText), typeof(string), typeof(FoundryLocalModelPicker), new PropertyMetadata(string.Empty, OnStatePropertyChanged)); + + public bool HasCachedModels => CachedModels?.Any() ?? false; + + public bool HasDownloadableModels => DownloadableModels?.Cast().Any() ?? false; + + public void RequestLoad() + { + if (IsLoading) + { + // Allow refresh requests to continue even if already loading by cancelling via host. + } + else + { + IsLoading = true; + } + + IsAvailable = false; + StatusText = "Loading Foundry Local status..."; + LoadRequested?.Invoke(this); + } + + private static void OnCachedModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToCachedModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnDownloadableModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToDownloadableModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnSelectedModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + if (control._suppressSelection) + { + return; + } + + try + { + control._suppressSelection = true; + if (control.CachedModelsComboBox is not null) + { + control.CachedModelsComboBox.SelectedItem = e.NewValue; + } + } + finally + { + control._suppressSelection = false; + } + + control.UpdateSelectedModelDetails(); + } + + private static void OnStatePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.UpdateVisualStates(); + } + + private void SubscribeToCachedModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_cachedModelsSubscription is not null) + { + _cachedModelsSubscription.CollectionChanged -= CachedModels_CollectionChanged; + _cachedModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += CachedModels_CollectionChanged; + _cachedModelsSubscription = observable; + } + } + + private void SubscribeToDownloadableModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_downloadableModelsSubscription is not null) + { + _downloadableModelsSubscription.CollectionChanged -= DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = observable; + } + } + + private void CachedModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void DownloadableModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void CachedModelsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressSelection) + { + return; + } + + try + { + _suppressSelection = true; + var selected = CachedModelsComboBox.SelectedItem as ModelDetails; + SetValue(SelectedModelProperty, selected); + SelectionChanged?.Invoke(this, selected); + } + finally + { + _suppressSelection = false; + } + + UpdateSelectedModelDetails(); + } + + private void UpdateSelectedModelDetails() + { + if (SelectedModelDetailsPanel is null || SelectedModelDescriptionText is null || SelectedModelTagsPanel is null) + { + return; + } + + if (!HasCachedModels || SelectedModel is not ModelDetails model) + { + SelectedModelDetailsPanel.Visibility = Visibility.Collapsed; + SelectedModelDescriptionText.Text = string.Empty; + SelectedModelTagsPanel.Children.Clear(); + SelectedModelTagsPanel.Visibility = Visibility.Collapsed; + return; + } + + SelectedModelDetailsPanel.Visibility = Visibility.Visible; + SelectedModelDescriptionText.Text = string.IsNullOrWhiteSpace(model.Description) + ? "No description provided." + : model.Description; + + SelectedModelTagsPanel.Children.Clear(); + + AddTag(GetModelSizeText(model.Size)); + AddTag(GetLicenseShortText(model.License), model.License); + + foreach (var deviceTag in GetDeviceTags(model.HardwareAccelerators)) + { + AddTag(deviceTag); + } + + SelectedModelTagsPanel.Visibility = SelectedModelTagsPanel.Children.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + void AddTag(string text, string tooltip = null) + { + if (string.IsNullOrWhiteSpace(text) || SelectedModelTagsPanel is null) + { + return; + } + + Border tag = new(); + if (Resources.TryGetValue("TagBorderStyle", out var borderStyleObj) && borderStyleObj is Style borderStyle) + { + tag.Style = borderStyle; + } + + TextBlock label = new() + { + Text = text, + }; + + if (Resources.TryGetValue("TagTextStyle", out var textStyleObj) && textStyleObj is Style textStyle) + { + label.Style = textStyle; + } + + tag.Child = label; + + if (!string.IsNullOrWhiteSpace(tooltip)) + { + ToolTipService.SetToolTip(tag, new TextBlock + { + Text = tooltip, + TextWrapping = TextWrapping.Wrap, + }); + } + + SelectedModelTagsPanel.Children.Add(tag); + } + } + + private void LaunchFoundryModelListButton_Click(object sender, RoutedEventArgs e) + { + try + { + ProcessStartInfo processInfo = new() + { + FileName = "powershell.exe", + Arguments = "-NoExit -Command \"foundry model list\"", + UseShellExecute = true, + }; + + Process.Start(processInfo); + StatusText = "Opening PowerShell and running 'foundry model list'..."; + } + catch (Exception ex) + { + StatusText = $"Unable to start PowerShell. {ex.Message}"; + Debug.WriteLine($"[FoundryLocalModelPicker] Failed to run 'foundry model list': {ex}"); + } + } + + private void RefreshModelsButton_Click(object sender, RoutedEventArgs e) + { + RequestLoad(); + } + + private void UpdateVisualStates() + { + LoadingIndicator.IsActive = IsLoading; + + if (IsLoading) + { + VisualStateManager.GoToState(this, "ShowLoading", true); + } + else if (!IsAvailable) + { + VisualStateManager.GoToState(this, "ShowNotAvailable", true); + } + else + { + VisualStateManager.GoToState(this, "ShowModels", true); + } + + if (LoadingStatusTextBlock is not null) + { + LoadingStatusTextBlock.Text = string.IsNullOrWhiteSpace(StatusText) + ? "Loading Foundry Local status..." + : StatusText; + } + + NoModelsPanel.Visibility = HasCachedModels ? Visibility.Collapsed : Visibility.Visible; + if (CachedModelsComboBox is not null) + { + CachedModelsComboBox.Visibility = HasCachedModels ? Visibility.Visible : Visibility.Collapsed; + CachedModelsComboBox.IsEnabled = HasCachedModels; + } + + UpdateSelectedModelDetails(); + + Bindings.Update(); + } + + public static string GetModelSizeText(long size) + { + if (size <= 0) + { + return string.Empty; + } + + const long kiloByte = 1024; + const long megaByte = kiloByte * 1024; + const long gigaByte = megaByte * 1024; + + if (size >= gigaByte) + { + return $"{size / (double)gigaByte:0.##} GB"; + } + + if (size >= megaByte) + { + return $"{size / (double)megaByte:0.##} MB"; + } + + if (size >= kiloByte) + { + return $"{size / (double)kiloByte:0.##} KB"; + } + + return $"{size} B"; + } + + public static Visibility GetModelSizeVisibility(long size) + { + return size > 0 ? Visibility.Visible : Visibility.Collapsed; + } + + public static IEnumerable GetDeviceTags(IReadOnlyCollection accelerators) + { + if (accelerators is null || accelerators.Count == 0) + { + return Array.Empty(); + } + + HashSet tags = new(StringComparer.OrdinalIgnoreCase); + + foreach (var accelerator in accelerators) + { + switch (accelerator) + { + case HardwareAccelerator.CPU: + tags.Add("CPU"); + break; + case HardwareAccelerator.GPU: + case HardwareAccelerator.DML: + tags.Add("GPU"); + break; + case HardwareAccelerator.NPU: + case HardwareAccelerator.QNN: + tags.Add("NPU"); + break; + } + } + + return tags.Count > 0 ? tags.ToArray() : Array.Empty(); + } + + public static Visibility GetDeviceVisibility(IReadOnlyCollection accelerators) + { + return GetDeviceTags(accelerators).Any() ? Visibility.Visible : Visibility.Collapsed; + } + + public static string GetLicenseShortText(string license) + { + if (string.IsNullOrWhiteSpace(license)) + { + return string.Empty; + } + + var trimmed = license.Trim(); + int separatorIndex = trimmed.IndexOfAny(['(', '[', ':']); + if (separatorIndex > 0) + { + trimmed = trimmed[..separatorIndex].Trim(); + } + + if (trimmed.Length > 24) + { + trimmed = $"{trimmed[..24].TrimEnd()}…"; + } + + return trimmed; + } + + public static Visibility GetLicenseVisibility(string license) + { + return string.IsNullOrWhiteSpace(license) ? Visibility.Collapsed : Visibility.Visible; + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml index 07b6a00c21..8dea006b08 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml @@ -111,7 +111,8 @@ x:Uid="UpdateAvailableInfoBar" IsClosable="False" IsOpen="{x:Bind ViewModel.IsUpdateAvailable, Mode=OneWay}" - Severity="Success" /> + Severity="Success" + Tapped="UpdateInfoBar_Tapped" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs index aad7dcf215..51219309e0 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs @@ -10,6 +10,7 @@ using Microsoft.PowerToys.Settings.UI.Controls; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -183,5 +184,14 @@ namespace Microsoft.PowerToys.Settings.UI.Flyout // Closing manually the flyout since no window will steal the focus App.GetFlyoutWindow()?.Hide(); } + + private void UpdateInfoBar_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + { + // Hide the flyout before opening settings window + App.GetFlyoutWindow()?.Hide(); + + // Open Settings window directly to General page where update controls are located + App.OpenSettingsWindow(typeof(GeneralPage)); + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index 628df84c01..ae4bbeb438 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -3,28 +3,43 @@ 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:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:viewmodels="using:Microsoft.PowerToys.Settings.UI.ViewModels" x:Name="RootPage" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> + + + ms-appx:///Assets/Settings/Modules/APDialog.dark.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.dark.svg ms-appx:///Assets/Settings/Modules/APDialog.light.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg ms-appx:///Assets/Settings/Modules/APDialog.light.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg + + + - + - + IsEnabled="{x:Bind ViewModel.IsOnlineAIModelsDisallowedByGPO, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" + IsExpanded="{x:Bind ViewModel.IsAIEnabled, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.PasteAIConfiguration.Providers, Mode=OneWay}"> + - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + DataContext="{x:Bind ViewModel.AdditionalActions.ImageToText, Mode=OneWay}" + HeaderIcon="{ui:FontIcon Glyph=}"> + + + + + + + + + + + + - - - - - - - - - - - - - - - - | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + 900 + 700 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs index a06e5838a4..0d7273f924 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs @@ -66,5 +66,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views App.GetOobeWindow().Activate(); } + + private void SortAlphabetical_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.Alphabetical; + } + + private void SortByStatus_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.ByStatus; + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml index fcdcc0f60d..cf67586a3e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml @@ -240,6 +240,7 @@ @@ -262,7 +263,10 @@ - + @@ -273,12 +277,16 @@ Name="GeneralPageRunAtStartUp" x:Uid="GeneralPage_RunAtStartUp" IsEnabled="{x:Bind ViewModel.IsRunAtStartupGPOManaged, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - + - + @@ -398,12 +406,16 @@ - + - + - - + + - + + + + + @@ -70,6 +85,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs index 72de0843d1..19375d90f7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs @@ -18,6 +18,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views InitializeComponent(); var settingsUtils = new SettingsUtils(); ViewModel = new HostsViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); + BackupsCountInputSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Description"); + BackupsCountInputAgeSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputAgeSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Age_Description"); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml index 7f55e31cd8..ec61a0fcd5 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml @@ -1,24 +1,23 @@  - - + - + + - - - + - - - + - - - - - - - - - - + + Text="{x:Bind ViewModel.LocationPanelLightTime, Converter={StaticResource TimeSpanToFriendlyTimeConverter}, Mode=OneWay}" + TextAlignment="Left" /> @@ -321,12 +325,12 @@ + Text="{x:Bind ViewModel.LocationPanelDarkTime, Converter={StaticResource TimeSpanToFriendlyTimeConverter}, Mode=OneWay}" + TextAlignment="Left" /> @@ -358,4 +362,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs index 4a8e8905d7..974447a20e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs @@ -12,88 +12,94 @@ using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; -using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using PowerToys.GPOWrapper; using Settings.UI.Library; -using Settings.UI.Library.Helpers; using Windows.Devices.Geolocation; -using Windows.Services.Maps; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class LightSwitchPage : Page + public sealed partial class LightSwitchPage : NavigablePage, IRefreshablePage { - private readonly string _appName = "LightSwitch"; - private readonly SettingsUtils _settingsUtils; - private readonly Func _sendConfigMsg = ShellPage.SendDefaultIPCMessage; + private readonly string appName = "LightSwitch"; + private readonly SettingsUtils settingsUtils; + private readonly Func sendConfigMsg = ShellPage.SendDefaultIPCMessage; - private readonly ISettingsRepository _generalSettingsRepository; - private readonly ISettingsRepository _moduleSettingsRepository; + private readonly SettingsRepository generalSettingsRepository; + private readonly SettingsRepository moduleSettingsRepository; - private readonly IFileSystem _fileSystem; - private readonly IFileSystemWatcher _fileSystemWatcher; - private readonly DispatcherQueue _dispatcherQueue; + private readonly IFileSystem fileSystem; + private readonly IFileSystemWatcher fileSystemWatcher; + private readonly DispatcherQueue dispatcherQueue; + private bool suppressViewModelUpdates; private LightSwitchViewModel ViewModel { get; set; } public LightSwitchPage() { - _settingsUtils = new SettingsUtils(); - _sendConfigMsg = ShellPage.SendDefaultIPCMessage; + this.settingsUtils = new SettingsUtils(); + this.sendConfigMsg = ShellPage.SendDefaultIPCMessage; - _generalSettingsRepository = SettingsRepository.GetInstance(_settingsUtils); - _moduleSettingsRepository = SettingsRepository.GetInstance(_settingsUtils); + this.generalSettingsRepository = SettingsRepository.GetInstance(this.settingsUtils); + this.moduleSettingsRepository = SettingsRepository.GetInstance(this.settingsUtils); // Get settings from JSON (or defaults if JSON missing) - var darkSettings = _moduleSettingsRepository.SettingsConfig; + var darkSettings = this.moduleSettingsRepository.SettingsConfig; // Pass them into the ViewModel - ViewModel = new LightSwitchViewModel(darkSettings, ShellPage.SendDefaultIPCMessage); - ViewModel.PropertyChanged += ViewModel_PropertyChanged; + this.ViewModel = new LightSwitchViewModel(darkSettings, this.sendConfigMsg); + this.ViewModel.PropertyChanged += ViewModel_PropertyChanged; - LoadSettings(_generalSettingsRepository, _moduleSettingsRepository); + this.LoadSettings(this.generalSettingsRepository, this.moduleSettingsRepository); - DataContext = ViewModel; + DataContext = this.ViewModel; - var settingsPath = _settingsUtils.GetSettingsFilePath(_appName); + var settingsPath = this.settingsUtils.GetSettingsFilePath(this.appName); - _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); - _fileSystem = new FileSystem(); + this.dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + this.fileSystem = new FileSystem(); - _fileSystemWatcher = _fileSystem.FileSystemWatcher.New(); - _fileSystemWatcher.Path = _fileSystem.Path.GetDirectoryName(settingsPath); - _fileSystemWatcher.Filter = _fileSystem.Path.GetFileName(settingsPath); - _fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime; - _fileSystemWatcher.Changed += Settings_Changed; - _fileSystemWatcher.EnableRaisingEvents = true; + this.fileSystemWatcher = this.fileSystem.FileSystemWatcher.New(); + this.fileSystemWatcher.Path = this.fileSystem.Path.GetDirectoryName(settingsPath); + this.fileSystemWatcher.Filter = this.fileSystem.Path.GetFileName(settingsPath); + this.fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime; + this.fileSystemWatcher.Changed += Settings_Changed; + this.fileSystemWatcher.EnableRaisingEvents = true; this.InitializeComponent(); - this.Loaded += LightSwitchPage_Loaded; - this.Loaded += (s, e) => ViewModel.OnPageLoaded(); + Loaded += LightSwitchPage_Loaded; + Loaded += (s, e) => this.ViewModel.OnPageLoaded(); + } + + public void RefreshEnabledState() + { + this.ViewModel.RefreshEnabledState(); } private void LightSwitchPage_Loaded(object sender, RoutedEventArgs e) { - if (ViewModel.SearchLocations.Count == 0) + if (this.ViewModel.SearchLocations.Count == 0) { foreach (var city in SearchLocationLoader.GetAll()) { - ViewModel.SearchLocations.Add(city); + this.ViewModel.SearchLocations.Add(city); } } - ViewModel.InitializeScheduleMode(); + this.ViewModel.InitializeScheduleMode(); } - private async Task GetGeoLocation() + private async void GetGeoLocation_Click(object sender, RoutedEventArgs e) { - SyncButton.IsEnabled = false; - SyncLoader.IsActive = true; - SyncLoader.Visibility = Visibility.Visible; + this.LatitudeBox.IsEnabled = false; + this.LongitudeBox.IsEnabled = false; + this.SyncButton.IsEnabled = false; + this.SyncLoader.IsActive = true; + this.SyncLoader.Visibility = Visibility.Visible; + this.LocationResultPanel.Visibility = Visibility.Collapsed; try { @@ -112,75 +118,110 @@ namespace Microsoft.PowerToys.Settings.UI.Views double latitude = Math.Round(pos.Coordinate.Point.Position.Latitude); double longitude = Math.Round(pos.Coordinate.Point.Position.Longitude); - SunTimes result = SunCalc.CalculateSunriseSunset( - latitude, - longitude, - DateTime.Now.Year, - DateTime.Now.Month, - DateTime.Now.Day); + ViewModel.LocationPanelLatitude = latitude; + ViewModel.LocationPanelLongitude = longitude; - ViewModel.LightTime = (result.SunriseHour * 60) + result.SunriseMinute; - ViewModel.DarkTime = (result.SunsetHour * 60) + result.SunsetMinute; - ViewModel.Latitude = latitude.ToString(CultureInfo.InvariantCulture); - ViewModel.Longitude = longitude.ToString(CultureInfo.InvariantCulture); + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LocationPanelLightTimeMinutes = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.LocationPanelDarkTimeMinutes = (result.SunsetHour * 60) + result.SunsetMinute; // Since we use this mode, we can remove the selected city data. - ViewModel.SelectedCity = null; - - // CityAutoSuggestBox.Text = string.Empty; - ViewModel.SyncButtonInformation = $"{ViewModel.Latitude}, {ViewModel.Longitude}"; + this.ViewModel.SelectedCity = null; // ViewModel.CityTimesText = $"Sunrise: {result.SunriseHour}:{result.SunriseMinute:D2}\n" + $"Sunset: {result.SunsetHour}:{result.SunsetMinute:D2}"; - SyncButton.IsEnabled = true; - SyncLoader.IsActive = false; - SyncLoader.Visibility = Visibility.Collapsed; - LocationDialog.IsPrimaryButtonEnabled = true; - LocationResultPanel.Visibility = Visibility.Visible; + this.SyncButton.IsEnabled = true; + this.SyncLoader.IsActive = false; + this.SyncLoader.Visibility = Visibility.Collapsed; + this.LocationDialog.IsPrimaryButtonEnabled = true; + this.LatitudeBox.IsEnabled = true; + this.LongitudeBox.IsEnabled = true; + this.LocationResultPanel.Visibility = Visibility.Visible; } catch (Exception ex) { - SyncButton.IsEnabled = true; - SyncLoader.IsActive = false; - System.Diagnostics.Debug.WriteLine("Location error: " + ex.Message); + this.SyncButton.IsEnabled = true; + this.SyncLoader.IsActive = false; + this.SyncLoader.Visibility = Visibility.Collapsed; + this.LocationResultPanel.Visibility = Visibility.Collapsed; + this.LatitudeBox.IsEnabled = true; + this.LongitudeBox.IsEnabled = true; + Logger.LogInfo($"Location error: " + ex.Message); + } + } + + private void LatLonBox_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) + { + double latitude = this.LatitudeBox.Value; + double longitude = this.LongitudeBox.Value; + + if (double.IsNaN(latitude) || double.IsNaN(longitude) || (latitude == 0 && longitude == 0)) + { + return; + } + + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LocationPanelLightTimeMinutes = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.LocationPanelDarkTimeMinutes = (result.SunsetHour * 60) + result.SunsetMinute; + + this.LocationResultPanel.Visibility = Visibility.Visible; + if (this.LocationDialog != null) + { + this.LocationDialog.IsPrimaryButtonEnabled = true; } } private void LocationDialog_PrimaryButtonClick(object sender, ContentDialogButtonClickEventArgs args) { - if (ViewModel.ScheduleMode == "SunriseToSunsetUser") + if (double.IsNaN(this.LatitudeBox.Value) || double.IsNaN(this.LongitudeBox.Value)) { - ViewModel.SyncButtonInformation = ViewModel.SelectedCity.City; - } - else if (ViewModel.ScheduleMode == "SunriseToSunsetGeo") - { - ViewModel.SyncButtonInformation = $"{ViewModel.Latitude}, {ViewModel.Longitude}"; + return; } - SunriseModeChartState(); + double latitude = this.LatitudeBox.Value; + double longitude = this.LongitudeBox.Value; + + // need to save the values + this.ViewModel.Latitude = latitude.ToString(CultureInfo.InvariantCulture); + this.ViewModel.Longitude = longitude.ToString(CultureInfo.InvariantCulture); + this.ViewModel.SyncButtonInformation = $"{this.ViewModel.Latitude}, {this.ViewModel.Longitude}"; + + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LightTime = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.DarkTime = (result.SunsetHour * 60) + result.SunsetMinute; + + this.SunriseModeChartState(); } private void ViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { + if (this.suppressViewModelUpdates) + { + return; + } + if (e.PropertyName == "IsEnabled") { - if (ViewModel.IsEnabled != _generalSettingsRepository.SettingsConfig.Enabled.LightSwitch) + if (this.ViewModel.IsEnabled != this.generalSettingsRepository.SettingsConfig.Enabled.LightSwitch) { - _generalSettingsRepository.SettingsConfig.Enabled.LightSwitch = ViewModel.IsEnabled; + this.generalSettingsRepository.SettingsConfig.Enabled.LightSwitch = this.ViewModel.IsEnabled; - var generalSettingsMessage = new OutGoingGeneralSettings(_generalSettingsRepository.SettingsConfig).ToString(); + var generalSettingsMessage = new OutGoingGeneralSettings(this.generalSettingsRepository.SettingsConfig).ToString(); Logger.LogInfo($"Saved general settings from Light Switch page."); - _sendConfigMsg?.Invoke(generalSettingsMessage); + this.sendConfigMsg?.Invoke(generalSettingsMessage); } } else { - if (ViewModel.ModuleSettings != null) + if (this.ViewModel.ModuleSettings != null) { - SndLightSwitchSettings currentSettings = new(_moduleSettingsRepository.SettingsConfig); + SndLightSwitchSettings currentSettings = new(this.moduleSettingsRepository.SettingsConfig); SndModuleSettings csIpcMessage = new(currentSettings); - SndLightSwitchSettings outSettings = new(ViewModel.ModuleSettings); + SndLightSwitchSettings outSettings = new(this.ViewModel.ModuleSettings); SndModuleSettings outIpcMessage = new(outSettings); string csMessage = csIpcMessage.ToJsonString(); @@ -190,13 +231,13 @@ namespace Microsoft.PowerToys.Settings.UI.Views { Logger.LogInfo($"Saved Light Switch settings from Light Switch page."); - _sendConfigMsg?.Invoke(outMessage); + this.sendConfigMsg?.Invoke(outMessage); } } } } - private void LoadSettings(ISettingsRepository generalSettingsRepository, ISettingsRepository moduleSettingsRepository) + private void LoadSettings(SettingsRepository generalSettingsRepository, SettingsRepository moduleSettingsRepository) { if (generalSettingsRepository != null) { @@ -221,8 +262,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views { if (generalSettings != null) { - ViewModel.IsEnabled = generalSettings.Enabled.LightSwitch; - ViewModel.ModuleSettings = (LightSwitchSettings)lightSwitchSettings.Clone(); + this.ViewModel.IsEnabled = generalSettings.Enabled.LightSwitch; + this.ViewModel.ModuleSettings = (LightSwitchSettings)lightSwitchSettings.Clone(); UpdateEnabledState(generalSettings.Enabled.LightSwitch); } @@ -239,10 +280,14 @@ namespace Microsoft.PowerToys.Settings.UI.Views private void Settings_Changed(object sender, FileSystemEventArgs e) { - _dispatcherQueue.TryEnqueue(() => + this.dispatcherQueue.TryEnqueue(() => { - _moduleSettingsRepository.ReloadSettings(); - LoadSettings(_generalSettingsRepository, _moduleSettingsRepository); + this.suppressViewModelUpdates = true; + + this.moduleSettingsRepository.ReloadSettings(); + this.LoadSettings(this.generalSettingsRepository, this.moduleSettingsRepository); + + this.suppressViewModelUpdates = false; }); } @@ -253,20 +298,20 @@ namespace Microsoft.PowerToys.Settings.UI.Views if (enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) { // Get the enabled state from GPO. - ViewModel.IsEnabledGpoConfigured = true; - ViewModel.EnabledGPOConfiguration = enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + this.ViewModel.IsEnabledGpoConfigured = true; + this.ViewModel.EnabledGPOConfiguration = enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; } else { - ViewModel.IsEnabled = recommendedState; + this.ViewModel.IsEnabled = recommendedState; } } private async void SyncLocationButton_Click(object sender, RoutedEventArgs e) { - LocationDialog.IsPrimaryButtonEnabled = false; - LocationResultPanel.Visibility = Visibility.Collapsed; - await LocationDialog.ShowAsync(); + this.LocationDialog.IsPrimaryButtonEnabled = false; + this.LocationResultPanel.Visibility = Visibility.Collapsed; + await this.LocationDialog.ShowAsync(); } private void CityAutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) @@ -276,7 +321,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views string query = sender.Text.ToLower(CultureInfo.CurrentCulture); // Filter your cities (assuming ViewModel.Cities is a List) - var filtered = ViewModel.SearchLocations + var filtered = this.ViewModel.SearchLocations .Where(c => (c.City?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false) || (c.Country?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false)) @@ -286,7 +331,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views } } - private void CityAutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + /* private void CityAutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) { if (args.SelectedItem is SearchLocation location) { @@ -296,43 +341,38 @@ namespace Microsoft.PowerToys.Settings.UI.Views LocationDialog.IsPrimaryButtonEnabled = true; LocationResultPanel.Visibility = Visibility.Visible; } - } + } */ private void ModeSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) { - switch (ViewModel.ScheduleMode) + switch (this.ViewModel.ScheduleMode) { case "FixedHours": VisualStateManager.GoToState(this, "ManualState", true); - TimelineCard.Visibility = Visibility.Visible; + this.TimelineCard.Visibility = Visibility.Visible; break; case "SunsetToSunrise": VisualStateManager.GoToState(this, "SunsetToSunriseState", true); - SunriseModeChartState(); + this.SunriseModeChartState(); break; default: VisualStateManager.GoToState(this, "OffState", true); - TimelineCard.Visibility = Visibility.Collapsed; + this.TimelineCard.Visibility = Visibility.Collapsed; break; } } - private async void LocationDialog_Opened(ContentDialog sender, ContentDialogOpenedEventArgs args) - { - await GetGeoLocation(); - } - private void SunriseModeChartState() { - if (ViewModel.Latitude != "0.0" && ViewModel.Longitude != "0.0") + if (this.ViewModel.Latitude != "0.0" && this.ViewModel.Longitude != "0.0") { - TimelineCard.Visibility = Visibility.Visible; - LocationWarningBar.Visibility = Visibility.Collapsed; + this.TimelineCard.Visibility = Visibility.Visible; + this.LocationWarningBar.Visibility = Visibility.Collapsed; } else { - TimelineCard.Visibility = Visibility.Collapsed; - LocationWarningBar.Visibility = Visibility.Visible; + this.TimelineCard.Visibility = Visibility.Collapsed; + this.LocationWarningBar.Visibility = Visibility.Visible; } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml index 498adf4803..60d0f5370d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml @@ -273,6 +273,34 @@ + + + + + + + + + + + + + + + + + + + - .GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), + SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml index 6a597a43bc..d837f35c3f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml @@ -23,7 +23,10 @@ Name="NewPlusEnableToggle" x:Uid="NewPlus_Enable_Toggle" HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/NewPlus.png}"> - + @@ -60,12 +63,18 @@ Name="NewPlusHideFileExtensionToggle" x:Uid="NewPlus_Hide_File_Extension_Toggle" IsEnabled="{x:Bind ViewModel.IsHideFileExtSettingsCardEnabled, Mode=OneWay}"> - + - + @@ -79,7 +88,10 @@ x:Uid="NewPlus_Behaviour_Replace_Variables_Toggle" IsEnabled="{x:Bind ViewModel.IsReplaceVariablesSettingsCardEnabled, Mode=OneWay}"> - +