diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 4d3980e8c5..41b05c2606 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -3,6 +3,7 @@ abcdefghjkmnpqrstuvxyz abgr ABlocked ABOUTBOX +ABORTIFHUNG Abug Acceleratorkeys ACCEPTFILES @@ -77,6 +78,7 @@ appwiz appxpackage APSTUDIO AQS +Aquadrant ARandom ARCHITEW ARemapped @@ -455,6 +457,7 @@ encryptor ENDSESSION ENSUREVISIBLE ENTERSIZEMOVE +ENTRYW ENU environmentvariables EOAC @@ -572,6 +575,7 @@ GETDESKWALLPAPER GETDLGCODE GETDPISCALEDSIZE getfilesiginforedist +geolocator GETHOTKEY GETICON GETMINMAXINFO @@ -828,6 +832,7 @@ lastcodeanalysissucceeded LASTEXITCODE LAYOUTRTL LCh +lbl lcid LCIDTo lcl @@ -846,6 +851,7 @@ LIBID LIMITSIZE LIMITTEXT lindex +lightswitch linkid LINKOVERLAY LINQTo @@ -869,10 +875,13 @@ logon LOGMSG LOGPIXELSX LOGPIXELSY +lng LOn +lon longdate LONGNAMES lowlevel +lquadrant LOWORD lparam LPBITMAPINFOHEADER @@ -1389,6 +1398,7 @@ quickaccent QUNS RAII RAlt +RAquadrant randi rasterization Rasterize @@ -1687,6 +1697,7 @@ Subdomain SUBMODULEUPDATE subresource Superbar +suntimes sut svchost SVGIn @@ -1752,6 +1763,7 @@ tgz themeresources THH THICKFRAME +THEMECHANGED THISCOMPONENT throughs thumbnailhotkey @@ -1768,6 +1780,7 @@ tkconverters tlb tlbimp tlc +tmain TNP TOGGLEEASYMOUSE Toolhelp diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 736f1eefd8..2a1760d94e 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -133,6 +133,9 @@ "PowerToys.ImageResizerContextMenu.dll", "ImageResizerContextMenuPackage.msix", + "PowerToys.LightSwitchModuleInterface.dll", + "LightSwitchService\\PowerToys.LightSwitchService.exe", + "PowerToys.KeyboardManager.dll", "KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe", "KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe", diff --git a/PowerToys.sln b/PowerToys.sln index 4c9c92ce47..50063816ea 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -5,11 +5,13 @@ MinimumVisualStudioVersion = 10.0.40219.1 Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "runner", "src\runner\runner.vcxproj", "{9412D5C6-2CF2-4FC2-A601-B55508EA9B27}" ProjectSection(ProjectDependencies) = postProject {031AC72E-FA28-4AB7-B690-6F7B9C28AA73} = {031AC72E-FA28-4AB7-B690-6F7B9C28AA73} + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC} = {08E71C67-6A7E-4CA1-B04E-2FB336410BAC} {0B43679E-EDFA-4DA0-AD30-F4628B308B1B} = {0B43679E-EDFA-4DA0-AD30-F4628B308B1B} {0B593A6C-4143-4337-860E-DB5710FB87DB} = {0B593A6C-4143-4337-860E-DB5710FB87DB} {17DA04DF-E393-4397-9CF0-84DABE11032E} = {17DA04DF-E393-4397-9CF0-84DABE11032E} {217DF501-135C-4E38-BFC8-99D4821032EA} = {217DF501-135C-4E38-BFC8-99D4821032EA} {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34} = {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34} + {38177D56-6AD1-4ADF-88C9-2843A7932166} = {38177D56-6AD1-4ADF-88C9-2843A7932166} {48804216-2A0E-4168-A6D8-9CD068D14227} = {48804216-2A0E-4168-A6D8-9CD068D14227} {51920F1F-C28C-4ADF-8660-4238766796C2} = {51920F1F-C28C-4ADF-8660-4238766796C2} {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} = {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} @@ -793,6 +795,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Window EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.UnitTestBase", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj", "{00D8659C-2068-40B6-8B86-759CD6284BBB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LightSwitch", "LightSwitch", "{5B201255-53C8-490B-A34F-01F05D48A477}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "LightSwitchModuleInterface", "src\modules\LightSwitch\LightSwitchModuleInterface\LightSwitchModuleInterface.vcxproj", "{38177D56-6AD1-4ADF-88C9-2843A7932166}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "LightSwitchService", "src\modules\LightSwitch\LightSwitchService\LightSwitchService.vcxproj", "{08E71C67-6A7E-4CA1-B04E-2FB336410BAC}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{E11826E1-76DF-42AC-985C-164CC2EE57A1}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScreenRuler.UITests", "src\modules\MeasureTool\Tests\ScreenRuler.UITests\ScreenRuler.UITests.csproj", "{66C069F8-C548-4CA6-8CDE-239104D68E88}" @@ -811,6 +819,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSea EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3DCCD936-D085-4869-A1DE-CA6A64152C94}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\modules\LightSwitch\Tests\LightSwitch.UITests\LightSwitch.UITests.csproj", "{F5333ED7-06D8-4AB3-953A-36D63F08CB6F}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", "{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}" EndProject Global @@ -2891,6 +2903,22 @@ Global {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|ARM64.Build.0 = Release|ARM64 {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|x64.ActiveCfg = Release|x64 {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|x64.Build.0 = Release|x64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Debug|ARM64.Build.0 = Debug|ARM64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Debug|x64.ActiveCfg = Debug|x64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Debug|x64.Build.0 = Debug|x64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Release|ARM64.ActiveCfg = Release|ARM64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Release|ARM64.Build.0 = Release|ARM64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Release|x64.ActiveCfg = Release|x64 + {38177D56-6AD1-4ADF-88C9-2843A7932166}.Release|x64.Build.0 = Release|x64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Debug|ARM64.Build.0 = Debug|ARM64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Debug|x64.ActiveCfg = Debug|x64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Debug|x64.Build.0 = Debug|x64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Release|ARM64.ActiveCfg = Release|ARM64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Release|ARM64.Build.0 = Release|ARM64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Release|x64.ActiveCfg = Release|x64 + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC}.Release|x64.Build.0 = Release|x64 {66C069F8-C548-4CA6-8CDE-239104D68E88}.Debug|ARM64.ActiveCfg = Debug|ARM64 {66C069F8-C548-4CA6-8CDE-239104D68E88}.Debug|ARM64.Build.0 = Debug|ARM64 {66C069F8-C548-4CA6-8CDE-239104D68E88}.Debug|x64.ActiveCfg = Debug|x64 @@ -2947,6 +2975,18 @@ Global {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Build.0 = Debug|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Deploy.0 = Debug|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|x64.ActiveCfg = Debug|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|x64.Build.0 = Debug|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|x64.Deploy.0 = Debug|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|ARM64.ActiveCfg = Release|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|ARM64.Build.0 = Release|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|ARM64.Deploy.0 = Release|ARM64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.ActiveCfg = Release|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.Build.0 = Release|x64 + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.Deploy.0 = Release|x64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|ARM64.ActiveCfg = Debug|ARM64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|ARM64.Build.0 = Debug|ARM64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|x64.ActiveCfg = Debug|x64 @@ -3268,6 +3308,9 @@ Global {E816D7AF-4688-4ECB-97CC-3D8E798F3828} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B0-4688-4ECB-97CC-3D8E798F3829} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {00D8659C-2068-40B6-8B86-759CD6284BBB} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {5B201255-53C8-490B-A34F-01F05D48A477} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} + {38177D56-6AD1-4ADF-88C9-2843A7932166} = {5B201255-53C8-490B-A34F-01F05D48A477} + {08E71C67-6A7E-4CA1-B04E-2FB336410BAC} = {5B201255-53C8-490B-A34F-01F05D48A477} {E11826E1-76DF-42AC-985C-164CC2EE57A1} = {7AC943C9-52E8-44CF-9083-744D8049667B} {66C069F8-C548-4CA6-8CDE-239104D68E88} = {E11826E1-76DF-42AC-985C-164CC2EE57A1} {9605B84E-FAC4-477B-B9EC-0753177EE6A8} = {557C4636-D7E1-4838-A504-7D19B725EE95} @@ -3277,6 +3320,8 @@ 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} + {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} + {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/doc/devdocs/modules/lightswitch.md b/doc/devdocs/modules/lightswitch.md new file mode 100644 index 0000000000..1e251dfff1 --- /dev/null +++ b/doc/devdocs/modules/lightswitch.md @@ -0,0 +1,107 @@ +# Light Switch + +[Public Overview – Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/light-switch) + +## Quick Links + +* [All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue%20state%3Aopen%20label%3AProduct-LightSwitch) +* [Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue%20state%3Aopen%20label%3AProduct-LightSwitch%20label%3AIssue-Bug) +* [Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-LightSwitch) + +## Overview + +The **Light Switch** module lets users automatically transition between light and dark mode using a timed schedule or a keyboard shortcut. + +## Features + +* Set custom times to start and stop dark mode. +* Use geolocation to determine local sunrise and sunset times. +* Apply offsets in sunrise mode (e.g., 15 minutes before sunset). +* Quickly toggle between modes with a keyboard shortcut (`Ctrl+Shift+Win+D` by default). +* Choose whether theme changes apply to: + + * Apps only + * System only + * Both apps and system + +## Architecture + +### Main Components + +* **Shortcut/Hotkey** + Listens for a hotkey event. Calling `onHotkey()` flips the theme flags. + + > **Note:** Using the shortcut overrides the current schedule until the next transition event. + +* **LightSwitchService** + Reads settings and applies theming. Runs a check every minute to ensure the state is correct. + +* **SettingsXAML/LightSwitch** + Provides the settings UI for configuring schedules, syncing location, and customizing shortcuts. + +* **Settings.UI/ViewModels/LightSwitchViewModel.cs** + Handles updates to the settings file and communicates changes to the front end. + +* **modules/LightSwitch/Tests** + Contains UI tests that verify interactions between the settings UI, system state, and `settings.json`. + +### Data Flow + +1. User configures settings in the UI (default: manual mode, light mode from 06:00–18:00). +2. Every minute, the service checks the time. + + * If it’s not a threshold, the service sleeps until the next minute. + * If it matches a threshold, the service applies the theme based on settings and returns to sleep. +3. At **midnight**, when in *Sunrise to Sunset* mode, the service updates daily sunrise and sunset times. +4. If the machine was asleep during a scheduled event, the service applies the correct settings at the next check. + +## User Interface + +The module’s settings are exposed in the PowerToys Settings UI. Options include: + +* Shortcut customization +* Mode selection (Manual or Sunrise to Sunset) +* Manual start/stop times (manual mode only) +* Automatic sunrise/sunset calculation (location-based) +* Time offsets (sunrise mode) +* Target scope (system, apps, or both) + +## Development Environment Setup + +### Prerequisites + +* Visual Studio 2019 or later +* Windows 10 SDK +* PowerToys repository cloned from GitHub + +### Building and Testing + +1. Clone the repo: + + ```sh + git clone https://github.com/microsoft/PowerToys.git + ``` +2. Initialize submodules: + + ```sh + git submodule update --init --recursive + ``` +3. Build the solution: + + ```sh + msbuild -restore -p:RestorePackagesConfig=true -p:Platform=ARM64 -m PowerToys.sln + ``` + + > Note: This may take some time. +4. Set `runner` as the startup project and press **F5**. +5. Enable Light Switch in PowerToys Settings. +6. To debug the service: + + * Press `Ctrl+Alt+P` or go to **Debug > Attach to Process**. + * Select `LightSwitchService.exe` and click **Attach**. + * You can now set breakpoints in the service files. +7. To debug the Settings UI: + + * Set the startup project to `PowerToys.Settings` and press **F5**. + * Note: Light Switch settings will not persist in this mode (they depend on the service executable). + * Alternatively, you can attach `PowerToys.Settings.exe` to the debugger while `runner` is running to test the full flow with breakpoints. diff --git a/doc/images/icons/Light Switch.png b/doc/images/icons/Light Switch.png new file mode 100644 index 0000000000..8a0778ff05 Binary files /dev/null and b/doc/images/icons/Light Switch.png differ diff --git a/doc/images/overview/LightSwitch_large.png b/doc/images/overview/LightSwitch_large.png new file mode 100644 index 0000000000..3a98b7f3e2 Binary files /dev/null and b/doc/images/overview/LightSwitch_large.png differ diff --git a/doc/images/overview/LightSwitch_small.png b/doc/images/overview/LightSwitch_small.png new file mode 100644 index 0000000000..c6e94735a9 Binary files /dev/null and b/doc/images/overview/LightSwitch_small.png differ diff --git a/doc/images/overview/Original/Light Switch.png b/doc/images/overview/Original/Light Switch.png new file mode 100644 index 0000000000..04e551a85d Binary files /dev/null and b/doc/images/overview/Original/Light Switch.png differ diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp index 815731c161..308b304591 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp @@ -1283,7 +1283,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) } processes.resize(bytes / sizeof(processes[0])); - std::array processesToTerminate = { + std::array processesToTerminate = { L"PowerToys.PowerLauncher.exe", L"PowerToys.Settings.exe", L"PowerToys.AdvancedPaste.exe", @@ -1298,6 +1298,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) L"PowerToys.Hosts.exe", L"PowerToys.PowerRename.exe", L"PowerToys.ImageResizer.exe", + L"PowerToys.LightSwitchService.exe", L"PowerToys.GcodeThumbnailProvider.exe", L"PowerToys.BgcodeThumbnailProvider.exe", L"PowerToys.PdfThumbnailProvider.exe", diff --git a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj index db6f6e6392..7cd49be6ea 100644 --- a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj +++ b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj @@ -65,6 +65,7 @@ call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Hosts.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Hosts.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\ImageResizer.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\ImageResizer.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\KeyboardManager.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\KeyboardManager.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\LightSwitch.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\LightSwitch.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\MouseWithoutBorders.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\MouseWithoutBorders.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\NewPlus.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\NewPlus.wxs.bk"""" call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Peek.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Peek.wxs.bk"""" diff --git a/installer/PowerToysSetupVNext/LightSwitch.wxs b/installer/PowerToysSetupVNext/LightSwitch.wxs new file mode 100644 index 0000000000..01f4bc329b --- /dev/null +++ b/installer/PowerToysSetupVNext/LightSwitch.wxs @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index a1b89ec1a9..5341f66768 100644 --- a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -41,6 +41,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil call move /Y ..\..\..\FileExplorerPreview.wxs.bk ..\..\..\FileExplorerPreview.wxs call move /Y ..\..\..\FileLocksmith.wxs.bk ..\..\..\FileLocksmith.wxs call move /Y ..\..\..\Hosts.wxs.bk ..\..\..\Hosts.wxs + call move /Y ..\..\..\LightSwitch.wxs.bk ..\..\..\LightSwitch.wxs call move /Y ..\..\..\ImageResizer.wxs.bk ..\..\..\ImageResizer.wxs call move /Y ..\..\..\KeyboardManager.wxs.bk ..\..\..\KeyboardManager.wxs call move /Y ..\..\..\MouseWithoutBorders.wxs.bk ..\..\..\MouseWithoutBorders.wxs @@ -114,6 +115,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil + diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs index 30831548dd..2505557d77 100644 --- a/installer/PowerToysSetupVNext/Product.wxs +++ b/installer/PowerToysSetupVNext/Product.wxs @@ -50,6 +50,7 @@ + diff --git a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 index fb63868f93..b6f2f88dd0 100644 --- a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 +++ b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 @@ -182,6 +182,10 @@ Generate-FileComponents -fileListName "HostsAssetsFiles" -wxsFilePath $PSScriptR Generate-FileList -fileDepsJson "" -fileListName ImageResizerAssetsFiles -wxsFilePath $PSScriptRoot\ImageResizer.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\ImageResizer" Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PSScriptRoot\ImageResizer.wxs -regroot $registryroot +# Light Switch Service +Generate-FileList -fileDepsJson "" -fileListName LightSwitchFiles -wxsFilePath $PSScriptRoot\LightSwitch.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\LightSwitchService" +Generate-FileComponents -fileListName "LightSwitchFiles" -wxsFilePath $PSScriptRoot\LightSwitch.wxs -regroot $registryroot + #New+ Generate-FileList -fileDepsJson "" -fileListName NewPlusAssetsFiles -wxsFilePath $PSScriptRoot\NewPlus.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\NewPlus" Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs -regroot $registryroot diff --git a/src/common/Common.UI/SettingsDeepLink.cs b/src/common/Common.UI/SettingsDeepLink.cs index 84945e6939..1891532d16 100644 --- a/src/common/Common.UI/SettingsDeepLink.cs +++ b/src/common/Common.UI/SettingsDeepLink.cs @@ -17,6 +17,7 @@ namespace Common.UI Awake, ColorPicker, CmdNotFound, + LightSwitch, FancyZones, FileLocksmith, Run, @@ -60,6 +61,8 @@ namespace Common.UI return "ColorPicker"; case SettingsWindow.CmdNotFound: return "CmdNotFound"; + case SettingsWindow.LightSwitch: + return "LightSwitch"; case SettingsWindow.FancyZones: return "FancyZones"; case SettingsWindow.FileLocksmith: diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 87ef1721b1..361255f66f 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -28,6 +28,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredCropAndLockEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredLightSwitchEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredLightSwitchEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredFancyZonesEnabledValue() { return static_cast(powertoys_gpo::getConfiguredFancyZonesEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index 33f90e15c9..c0fff9f542 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -13,6 +13,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredCmdPalEnabledValue(); static GpoRuleConfigured GetConfiguredColorPickerEnabledValue(); static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue(); + static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue(); static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue(); static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue(); static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 252b4d128a..630beab9c9 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -17,6 +17,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredCmdPalEnabledValue(); static GpoRuleConfigured GetConfiguredColorPickerEnabledValue(); static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue(); + static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue(); static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue(); static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue(); static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue(); diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index 65b00d4b5a..aa741e2f3a 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -19,6 +19,7 @@ namespace ManagedCommon Hosts, ImageResizer, KeyboardManager, + LightSwitch, MouseHighlighter, MouseJump, MousePointerCrosshairs, diff --git a/src/common/UITestAutomation/Element/Element.cs b/src/common/UITestAutomation/Element/Element.cs index 1868a9c34d..6e9efabeac 100644 --- a/src/common/UITestAutomation/Element/Element.cs +++ b/src/common/UITestAutomation/Element/Element.cs @@ -81,6 +81,14 @@ namespace Microsoft.PowerToys.UITest get { return this.windowsElement?.Selected ?? false; } } + /// + /// Gets a value indicating whether the UI element is visible to the user. + /// + public bool Displayed + { + get { return this.windowsElement?.Displayed ?? false; } + } + /// /// Gets the Rect of the UI element. /// @@ -329,7 +337,7 @@ namespace Microsoft.PowerToys.UITest /// Send Key of the element. /// /// The Key to Send. - protected void SendKeys(string key) + public void SendKeys(string key) { PerformAction((actions, windowElement) => { @@ -369,5 +377,19 @@ namespace Microsoft.PowerToys.UITest Assert.IsNotNull(this.windowsElement, $"WindowsElement is null in method SaveToPngFile with parameter: path = {path}"); this.windowsElement.GetScreenshot().SaveAsFile(path); } + + public void EnsureVisible(Element scrollViewer, int maxScrolls = 10) + { + int count = 0; + if (scrollViewer.WindowsElement != null) + { + while (!this.windowsElement!.Displayed && count < maxScrolls) + { + scrollViewer.WindowsElement.SendKeys(OpenQA.Selenium.Keys.PageDown); + Task.Delay(250).Wait(); + count++; + } + } + } } } diff --git a/src/common/UITestAutomation/ModuleConfigData.cs b/src/common/UITestAutomation/ModuleConfigData.cs index ac3f5ffe26..4dcd168da3 100644 --- a/src/common/UITestAutomation/ModuleConfigData.cs +++ b/src/common/UITestAutomation/ModuleConfigData.cs @@ -34,6 +34,7 @@ namespace Microsoft.PowerToys.UITest PowerRename, CommandPalette, ScreenRuler, + LightSwitch, } /// @@ -106,6 +107,7 @@ namespace Microsoft.PowerToys.UITest [PowerToysModule.PowerRename] = new ModuleInfo("PowerToys.PowerRename.exe", "PowerRename", "WinUI3Apps"), [PowerToysModule.CommandPalette] = new ModuleInfo("Microsoft.CmdPal.UI.exe", "PowerToys Command Palette", "WinUI3Apps\\CmdPal"), [PowerToysModule.ScreenRuler] = new ModuleInfo("PowerToys.MeasureToolUI.exe", "PowerToys.ScreenRuler", "WinUI3Apps"), + [PowerToysModule.LightSwitch] = new ModuleInfo("PowerToys.LightSwitch.exe", "PowerToys.LightSwitch", "LightSwitchService"), }; } diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index 00cde3b485..b2e05fadfe 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -81,6 +81,7 @@ struct LogSettings inline const static std::string workspacesSnapshotToolLoggerName = "workspaces-snapshot-tool"; inline const static std::wstring workspacesSnapshotToolLogPath = L"workspaces-snapshot-tool-log.log"; inline const static std::string zoomItLoggerName = "zoom-it"; + inline const static std::string lightSwitchLoggerName = "light-switch"; inline const static int retention = 30; std::wstring logLevel; LogSettings(); diff --git a/src/common/utils/elevation.h b/src/common/utils/elevation.h index 7f2ecbf6df..e412ce5aa3 100644 --- a/src/common/utils/elevation.h +++ b/src/common/utils/elevation.h @@ -257,7 +257,9 @@ inline HANDLE run_elevated(const std::wstring& file, const std::wstring& params, exec_info.nShow = SW_HIDE; } - return ShellExecuteExW(&exec_info) ? exec_info.hProcess : nullptr; + BOOL result = ShellExecuteExW(&exec_info); + + return result ? exec_info.hProcess : nullptr; } // Run command as non-elevated user, returns true if succeeded, puts the process id into returnPid if returnPid != NULL diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index ed60bc1a37..471cefe480 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -30,6 +30,7 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_CMD_NOT_FOUND = L"ConfigureEnabledUtilityCmdNotFound"; const std::wstring POLICY_CONFIGURE_ENABLED_COLOR_PICKER = L"ConfigureEnabledUtilityColorPicker"; const std::wstring POLICY_CONFIGURE_ENABLED_CROP_AND_LOCK = L"ConfigureEnabledUtilityCropAndLock"; + const std::wstring POLICY_CONFIGURE_ENABLED_LIGHT_SWITCH = L"ConfigureEnabledUtilityLightSwitch"; const std::wstring POLICY_CONFIGURE_ENABLED_FANCYZONES = L"ConfigureEnabledUtilityFancyZones"; const std::wstring POLICY_CONFIGURE_ENABLED_FILE_LOCKSMITH = L"ConfigureEnabledUtilityFileLocksmith"; const std::wstring POLICY_CONFIGURE_ENABLED_SVG_PREVIEW = L"ConfigureEnabledUtilityFileExplorerSVGPreview"; @@ -295,6 +296,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_CROP_AND_LOCK); } + inline gpo_rule_configured_t getConfiguredLightSwitchEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_LIGHT_SWITCH); + } + inline gpo_rule_configured_t getConfiguredFancyZonesEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_FANCYZONES); diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index 685eeaf350..07d4f44bde 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -137,6 +137,16 @@ + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 7fe996abcc..2703358bb0 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -245,6 +245,7 @@ If you don't configure this policy, the user will be able to control the setting Command Not Found: Configure enabled state CmdPal: Configure enabled state Crop And Lock: Configure enabled state + Light Switch: Configure enabled state Environment Variables: Configure enabled state FancyZones: Configure enabled state File Locksmith: Configure enabled state diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.rc b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.rc new file mode 100644 index 0000000000..98694dca49 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.rc @@ -0,0 +1,32 @@ +1 VERSIONINFO + FILEVERSION 0,1,0,0 + PRODUCTVERSION 0,1,0,0 + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x2L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Company Name" + VALUE "FileDescription", "Light Switch Module" + VALUE "FileVersion", "0.1.0.0" + VALUE "InternalName", "Light Switch" + VALUE "LegalCopyright", "Copyright (C) 2019 Company Name" + VALUE "OriginalFilename", "PowerToys.LightSwitchModuleInterface.dll" + VALUE "ProductName", "Light Switch" + VALUE "ProductVersion", "0.1.0.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj new file mode 100644 index 0000000000..261cfab1e6 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj @@ -0,0 +1,225 @@ + + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + 15.0 + {38177d56-6ad1-4adf-88c9-2843a7932166} + Win32Proj + LightSwitchModuleInterface + 10.0 + LightSwitchModuleInterface + PowerToys.LightSwitchModuleInterface + + + + DynamicLibrary + true + v142 + Unicode + + + DynamicLibrary + false + v142 + true + Unicode + + + DynamicLibrary + true + v142 + Unicode + + + DynamicLibrary + false + v142 + true + Unicode + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\ + + + true + + + false + + + + Use + Level3 + Disabled + true + _DEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreadedDebug + stdcpplatest + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Use + Level3 + MaxSpeed + true + true + true + NDEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreaded + stdcpplatest + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Use + Level3 + Disabled + true + _DEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreadedDebug + stdcpplatest + Use + pch.h + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Use + Level3 + MaxSpeed + true + true + true + NDEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreaded + stdcpplatest + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + $(CoreLibraryDependencies);%(AdditionalDependencies);advapi32.lib + + + + + + + + + + + + Create + Create + Create + Create + pch.h + pch.h + pch.h + pch.h + + + + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {4aed67b6-55fd-486f-b917-e543dee2cb3c} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters new file mode 100644 index 0000000000..45352efe4b --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters @@ -0,0 +1,50 @@ + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + {bbf22ac8-46f8-4206-b44b-9c3897e99ce5} + + + {530ed784-9a70-46a0-8fb6-20d5dee4f7d3} + + + {da1cb871-86d3-414c-adf5-a7e9f2077d2f} + + + + + Resource Files + + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.cpp new file mode 100644 index 0000000000..dff2a67669 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.cpp @@ -0,0 +1,81 @@ +#include "pch.h" +#include +#include "ThemeHelper.h" + +// Controls changing the themes. + +void SetAppsTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"AppsUseLightTheme", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +void SetSystemTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"SystemUsesLightTheme", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +bool GetCurrentSystemTheme() +{ + HKEY hKey; + DWORD value = 1; // default = light + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"SystemUsesLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} + +bool GetCurrentAppsTheme() +{ + HKEY hKey; + DWORD value = 1; + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"AppsUseLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.h new file mode 100644 index 0000000000..5985fd95c8 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/ThemeHelper.h @@ -0,0 +1,5 @@ +#pragma once +void SetSystemTheme(bool dark); +void SetAppsTheme(bool dark); +bool GetCurrentSystemTheme(); +bool GetCurrentAppsTheme(); diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp new file mode 100644 index 0000000000..8a1faf1e9b --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -0,0 +1,570 @@ +#include "pch.h" +#include +#include "trace.h" +#include +#include +#include +#include +#include +#include +#include "ThemeHelper.h" + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_WIN[] = L"win"; + const wchar_t JSON_KEY_ALT[] = L"alt"; + const wchar_t JSON_KEY_CTRL[] = L"ctrl"; + const wchar_t JSON_KEY_SHIFT[] = L"shift"; + const wchar_t JSON_KEY_CODE[] = L"code"; + const wchar_t JSON_KEY_TOGGLE_THEME_HOTKEY[] = L"toggle-theme-hotkey"; + const wchar_t JSON_KEY_VALUE[] = L"value"; +} + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +// The PowerToy name that will be shown in the settings. +const static wchar_t* MODULE_NAME = L"LightSwitch"; +// Add a description that will we shown in the module settings page. +const static wchar_t* MODULE_DESC = L"This is a module that allows you to control light/dark theming via set times, sun rise, or directly invoking the change."; + +enum class ScheduleMode +{ + FixedHours, + SunsetToSunrise, + // add more later +}; + +inline std::wstring ToString(ScheduleMode mode) +{ + switch (mode) + { + case ScheduleMode::SunsetToSunrise: + return L"SunsetToSunrise"; + case ScheduleMode::FixedHours: + default: + return L"FixedHours"; + } +} + +inline ScheduleMode FromString(const std::wstring& str) +{ + if (str == L"SunsetToSunrise") + return ScheduleMode::SunsetToSunrise; + return ScheduleMode::FixedHours; +} + +// These are the properties shown in the Settings page. +struct ModuleSettings +{ + bool m_changeSystem = true; + bool m_changeApps = true; + ScheduleMode m_scheduleMode = ScheduleMode::FixedHours; + int m_lightTime = 480; + int m_darkTime = 1200; + int m_sunrise_offset = 0; + int m_sunset_offset = 0; + std::wstring m_latitude = L"0.0"; + std::wstring m_longitude = L"0.0"; +} g_settings; + +class LightSwitchInterface : public PowertoyModuleIface +{ +private: + bool m_enabled = false; + + HANDLE m_process{ nullptr }; + HANDLE m_force_light_event_handle; + HANDLE m_force_dark_event_handle; + HANDLE m_manual_override_event_handle; + + static const constexpr int NUM_DEFAULT_HOTKEYS = 4; + + Hotkey m_toggle_theme_hotkey = { .win = true, .ctrl = true, .shift = true, .alt = false, .key = 'D' }; + + void init_settings(); + +public: + LightSwitchInterface() + { + LoggerHelpers::init_logger(L"LightSwitch", L"ModuleInterface", LogSettings::lightSwitchLoggerName); + + m_force_light_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_LIGHT"); + m_force_dark_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_DARK"); + m_manual_override_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + + init_settings(); + }; + + virtual const wchar_t* get_key() override + { + return L"LightSwitch"; + } + + // Destroy the powertoy and free memory + virtual void destroy() override + { + delete this; + } + + // Return the display name of the powertoy, this will be cached by the runner + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + // Return the configured status for the gpo policy for the module + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredLightSwitchEnabledValue(); + } + + // Return JSON with the configuration options. + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + + // Create a Settings object with your module name + PowerToysSettings::Settings settings(hinstance, get_name()); + settings.set_description(MODULE_DESC); + settings.set_overview_link(L"https://aka.ms/powertoys"); + + // Boolean toggles + settings.add_bool_toggle( + L"changeSystem", + L"Change System Theme", + g_settings.m_changeSystem); + + settings.add_bool_toggle( + L"changeApps", + L"Change Apps Theme", + g_settings.m_changeApps); + + settings.add_choice_group( + L"scheduleMode", + L"Theme schedule mode", + ToString(g_settings.m_scheduleMode), + { { L"FixedHours", L"Set hours manually" }, + { L"SunsetToSunrise", L"Use sunrise/sunset times" } }); + + // Integer spinners + settings.add_int_spinner( + L"lightTime", + L"Time to switch to light theme (minutes after midnight).", + g_settings.m_lightTime, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"darkTime", + L"Time to switch to dark theme (minutes after midnight).", + g_settings.m_darkTime, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"sunrise_offset", + L"Time to offset turning on your light theme.", + g_settings.m_sunrise_offset, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"sunset_offset", + L"Time to offset turning on your dark theme.", + g_settings.m_sunset_offset, + 0, + 1439, + 1); + + // Strings for latitude and longitude + settings.add_string( + L"latitude", + L"Your latitude in decimal degrees (e.g. 39.95).", + g_settings.m_latitude); + + settings.add_string( + L"longitude", + L"Your longitude in decimal degrees (e.g. -75.16).", + g_settings.m_longitude); + + // One-shot actions (buttons) + settings.add_custom_action( + L"forceLight", + L"Switch immediately to light theme", + L"Force Light", + L"{}"); + + settings.add_custom_action( + L"forceDark", + L"Switch immediately to dark theme", + L"Force Dark", + L"{}"); + + // Hotkeys + PowerToysSettings::HotkeyObject dm_hk = PowerToysSettings::HotkeyObject::from_settings( + m_toggle_theme_hotkey.win, + m_toggle_theme_hotkey.ctrl, + m_toggle_theme_hotkey.alt, + m_toggle_theme_hotkey.shift, + m_toggle_theme_hotkey.key); + + settings.add_hotkey( + L"toggle-theme-hotkey", + L"Shortcut to toggle theme immediately", + dm_hk); + + // Serialize to buffer for the PowerToys runner + return settings.serialize_to_buffer(buffer, buffer_size); + } + + // Signal from the Settings editor to call a custom action. + // This can be used to spawn more complex editors. + void call_custom_action(const wchar_t* action) override + { + try + { + auto action_object = PowerToysSettings::CustomActionObject::from_json_string(action); + + if (action_object.get_name() == L"forceLight") + { + Logger::info(L"[Light Switch] Custom action triggered: Force Light"); + SetSystemTheme(true); + SetAppsTheme(true); + } + else if (action_object.get_name() == L"forceDark") + { + Logger::info(L"[Light Switch] Custom action triggered: Force Dark"); + SetSystemTheme(false); + SetAppsTheme(false); + } + } + catch (...) + { + Logger::error(L"[Light Switch] Invalid custom action JSON"); + } + } + + // Called by the runner to pass the updated settings values as a serialized JSON. + virtual void set_config(const wchar_t* config) override + { + try + { + auto values = PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + + parse_hotkey(values); + + if (auto v = values.get_bool_value(L"changeSystem")) + { + g_settings.m_changeSystem = *v; + } + + if (auto v = values.get_bool_value(L"changeApps")) + { + g_settings.m_changeApps = *v; + } + + if (auto v = values.get_string_value(L"scheduleMode")) + { + g_settings.m_scheduleMode = FromString(*v); + } + + if (auto v = values.get_int_value(L"lightTime")) + { + g_settings.m_lightTime = *v; + } + + if (auto v = values.get_int_value(L"darkTime")) + { + g_settings.m_darkTime = *v; + } + + if (auto v = values.get_int_value(L"sunrise_offset")) + { + g_settings.m_sunrise_offset = *v; + } + + if (auto v = values.get_int_value(L"m_sunset_offset")) + { + g_settings.m_sunset_offset = *v; + } + + if (auto v = values.get_string_value(L"latitude")) + { + g_settings.m_latitude = *v; + } + if (auto v = values.get_string_value(L"longitude")) + { + g_settings.m_longitude = *v; + } + + values.save_to_settings_file(); + } + catch (const std::exception&) + { + Logger::error("[Light Switch] set_config: Failed to parse or apply config."); + } + } + + virtual void enable() + { + m_enabled = true; + Logger::info(L"Enabling Light Switch module..."); + + unsigned long powertoys_pid = GetCurrentProcessId(); + std::wstring args = L"--pid " + std::to_wstring(powertoys_pid); + std::wstring exe_name = L"LightSwitchService\\PowerToys.LightSwitchService.exe"; + + std::wstring resolved_path(MAX_PATH, L'\0'); + DWORD result = SearchPathW( + nullptr, + exe_name.c_str(), + nullptr, + static_cast(resolved_path.size()), + resolved_path.data(), + nullptr); + + if (result == 0 || result >= resolved_path.size()) + { + Logger::error( + L"Failed to locate Light Switch executable named '{}' at location '{}'", + exe_name, + resolved_path.c_str()); + return; + } + + resolved_path.resize(result); + Logger::debug(L"Resolved executable path: {}", resolved_path); + + std::wstring command_line = L"\"" + resolved_path + L"\" " + args; + + STARTUPINFO si = { sizeof(si) }; + PROCESS_INFORMATION pi; + + if (!CreateProcessW( + resolved_path.c_str(), + command_line.data(), + nullptr, + nullptr, + TRUE, + 0, + nullptr, + nullptr, + &si, + &pi)) + { + Logger::error(L"Failed to launch Light Switch process. {}", get_last_error_or_default(GetLastError())); + return; + } + + Logger::info(L"Light Switch process launched successfully (PID: {}).", pi.dwProcessId); + m_process = pi.hProcess; + CloseHandle(pi.hThread); + } + + // Disable the powertoy + virtual void disable() + { + Logger::info("Light Switch disabling"); + m_enabled = false; + + if (m_process) + { + constexpr DWORD timeout_ms = 1500; + DWORD result = WaitForSingleObject(m_process, timeout_ms); + + if (result == WAIT_TIMEOUT) + { + Logger::warn("Light Switch: Process didn't exit in time. Forcing termination."); + TerminateProcess(m_process, 0); + } + + CloseHandle(m_manual_override_event_handle); + m_manual_override_event_handle = nullptr; + + CloseHandle(m_process); + m_process = nullptr; + } + } + + // Returns if the powertoys is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + void parse_hotkey(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + Hotkey _temp_toggle_theme; + auto jsonHotkeyObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_TOGGLE_THEME_HOTKEY).GetNamedObject(JSON_KEY_VALUE); + _temp_toggle_theme.win = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_WIN); + _temp_toggle_theme.alt = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_ALT); + _temp_toggle_theme.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT); + _temp_toggle_theme.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL); + _temp_toggle_theme.key = static_cast(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE)); + m_toggle_theme_hotkey = _temp_toggle_theme; + } + catch (...) + { + Logger::error("Failed to initialize Light Switch force dark mode shortcut from settings. Value will keep unchanged."); + } + } + else + { + Logger::info("Light Switch settings are empty"); + } + } + + virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override + { + if (hotkeys && buffer_size >= 1) + { + hotkeys[0] = m_toggle_theme_hotkey; + } + return 1; + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (m_enabled) + { + Logger::trace(L"Light Switch hotkey pressed"); + if (!is_process_running()) + { + enable(); + } + else if (hotkeyId == 0) + { + // get current will return true if in light mode, otherwise false + Logger::info(L"[Light Switch] Hotkey triggered: Toggle Theme"); + if (g_settings.m_changeSystem) + { + SetSystemTheme(!GetCurrentSystemTheme()); + } + if (g_settings.m_changeApps) + { + SetAppsTheme(!GetCurrentAppsTheme()); + } + + if (m_manual_override_event_handle) + { + SetEvent(m_manual_override_event_handle); + Logger::debug(L"[Light Switch] Manual override event set"); + } + } + + return true; + } + + return false; + } + + bool is_process_running() + { + return WaitForSingleObject(m_process, 0) == WAIT_TIMEOUT; + } +}; + +std::wstring utf8_to_wstring(const std::string& str) +{ + if (str.empty()) + return std::wstring(); + + int size_needed = MultiByteToWideChar( + CP_UTF8, + 0, + str.c_str(), + static_cast(str.size()), + nullptr, + 0); + + std::wstring wstr(size_needed, 0); + + MultiByteToWideChar( + CP_UTF8, + 0, + str.c_str(), + static_cast(str.size()), + &wstr[0], + size_needed); + + return wstr; +} + +// Load the settings file. +void LightSwitchInterface::init_settings() +{ + Logger::info(L"[Light Switch] init_settings: starting to load settings for module"); + + try + { + PowerToysSettings::PowerToyValues settings = + PowerToysSettings::PowerToyValues::load_from_settings_file(get_name()); + + parse_hotkey(settings); + + if (auto v = settings.get_bool_value(L"changeSystem")) + g_settings.m_changeSystem = *v; + if (auto v = settings.get_bool_value(L"changeApps")) + g_settings.m_changeApps = *v; + if (auto v = settings.get_string_value(L"scheduleMode")) + g_settings.m_scheduleMode = FromString(*v); + if (auto v = settings.get_int_value(L"lightTime")) + g_settings.m_lightTime = *v; + if (auto v = settings.get_int_value(L"darkTime")) + g_settings.m_darkTime = *v; + if (auto v = settings.get_int_value(L"sunrise_offset")) + g_settings.m_sunrise_offset = *v; + if (auto v = settings.get_int_value(L"sunset_offset")) + g_settings.m_sunset_offset = *v; + if (auto v = settings.get_string_value(L"latitude")) + g_settings.m_latitude = *v; + if (auto v = settings.get_string_value(L"longitude")) + g_settings.m_longitude = *v; + + Logger::info(L"[Light Switch] init_settings: loaded successfully"); + } + catch (const winrt::hresult_error& e) + { + Logger::error(L"[Light Switch] init_settings: hresult_error 0x{:08X} - {}", e.code(), e.message().c_str()); + } + catch (const std::exception& e) + { + std::wstring whatStr = utf8_to_wstring(e.what()); + Logger::error(L"[Light Switch] init_settings: std::exception - {}", whatStr); + } + catch (...) + { + Logger::error(L"[Light Switch] init_settings: unknown exception while loading settings"); + } +} + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new LightSwitchInterface(); +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp new file mode 100644 index 0000000000..a83d3bb2cc --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp @@ -0,0 +1,2 @@ +#include "pch.h" +#pragma comment(lib, "windowsapp") \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h new file mode 100644 index 0000000000..39f8f4ac84 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h @@ -0,0 +1,14 @@ +#pragma once +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/resource.h b/src/modules/LightSwitch/LightSwitchModuleInterface/resource.h new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp new file mode 100644 index 0000000000..57fa1921f7 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp @@ -0,0 +1,30 @@ +#include "pch.h" +#include "trace.h" +#include + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::MyEvent() +{ + TraceLoggingWrite( + g_hProvider, + "PowerToyName_MyEvent", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h new file mode 100644 index 0000000000..55cdedb2ee --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +TRACELOGGING_DECLARE_PROVIDER(g_hProvider); + +class Trace +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void MyEvent(); +}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico b/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico new file mode 100644 index 0000000000..ee1be50010 Binary files /dev/null and b/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico differ diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp new file mode 100644 index 0000000000..168ee092e7 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -0,0 +1,295 @@ +#include +#include +#include "ThemeScheduler.h" +#include "ThemeHelper.h" +#include +#include +#include +#include +#include +#include + +SERVICE_STATUS g_ServiceStatus = {}; +SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; +HANDLE g_ServiceStopEvent = nullptr; +static int g_lastUpdatedDay = -1; + +VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); +VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); +DWORD WINAPI ServiceWorkerThread(LPVOID lpParam); + +// Entry point for the executable +int _tmain(int argc, TCHAR* argv[]) +{ + DWORD parentPid = 0; + bool debug = false; + for (int i = 1; i < argc; ++i) + { + if (_tcscmp(argv[i], _T("--debug")) == 0) + debug = true; + else if (_tcscmp(argv[i], _T("--pid")) == 0 && i + 1 < argc) + parentPid = _tstoi(argv[++i]); + } + + // Try to connect to SCM + wchar_t serviceName[] = L"LightSwitchService"; + SERVICE_TABLE_ENTRYW table[] = { { serviceName, ServiceMain }, { nullptr, nullptr } }; + + if (!StartServiceCtrlDispatcherW(table)) + { + DWORD err = GetLastError(); + if (err == ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) // not launched by SCM + { + g_ServiceStopEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); + HANDLE hThread = CreateThread( + nullptr, 0, ServiceWorkerThread, reinterpret_cast(static_cast(parentPid)), 0, nullptr); + + // Wait so the process stays alive + WaitForSingleObject(hThread, INFINITE); + CloseHandle(hThread); + CloseHandle(g_ServiceStopEvent); + return 0; + } + return static_cast(err); + } + + return 0; +} + +// Called when the service is launched by Windows +VOID WINAPI ServiceMain(DWORD, LPTSTR*) +{ + g_StatusHandle = RegisterServiceCtrlHandler(_T("LightSwitchService"), ServiceCtrlHandler); + if (!g_StatusHandle) + return; + + g_ServiceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + g_ServiceStatus.dwControlsAccepted = SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN; + g_ServiceStatus.dwCurrentState = SERVICE_START_PENDING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + g_ServiceStopEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); + if (!g_ServiceStopEvent) + { + g_ServiceStatus.dwCurrentState = SERVICE_STOPPED; + g_ServiceStatus.dwWin32ExitCode = GetLastError(); + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + return; + } + + SECURITY_ATTRIBUTES sa{ sizeof(sa) }; + sa.bInheritHandle = FALSE; + sa.lpSecurityDescriptor = nullptr; + + g_ServiceStatus.dwCurrentState = SERVICE_RUNNING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + HANDLE hThread = CreateThread(nullptr, 0, ServiceWorkerThread, nullptr, 0, nullptr); + WaitForSingleObject(hThread, INFINITE); + CloseHandle(hThread); + + CloseHandle(g_ServiceStopEvent); + g_ServiceStatus.dwCurrentState = SERVICE_STOPPED; + g_ServiceStatus.dwWin32ExitCode = 0; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); +} + +VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl) +{ + switch (dwCtrl) + { + case SERVICE_CONTROL_STOP: + if (g_ServiceStatus.dwCurrentState != SERVICE_RUNNING) + break; + + g_ServiceStatus.dwCurrentState = SERVICE_STOP_PENDING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + // Signal the service to stop + SetEvent(g_ServiceStopEvent); + break; + + default: + break; + } +} + +static void update_sun_times(auto& settings) +{ + double latitude = std::stod(settings.latitude); + double longitude = std::stod(settings.longitude); + + SYSTEMTIME st; + GetLocalTime(&st); + + SunTimes newTimes = CalculateSunriseSunset(latitude, longitude, st.wYear, st.wMonth, st.wDay); + + int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute; + int newDarkTime = newTimes.sunsetHour * 60 + newTimes.sunsetMinute; + + auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); + values.add_property(L"lightTime", newLightTime); + values.add_property(L"darkTime", newDarkTime); + values.save_to_settings_file(); + + OutputDebugString(L"[LightSwitchService] Updated sun times and saved to config.\n"); +} + +DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) +{ + DWORD parentPid = static_cast(reinterpret_cast(lpParam)); + HANDLE hParent = nullptr; + if (parentPid) + hParent = OpenProcess(SYNCHRONIZE, FALSE, parentPid); + + OutputDebugString(L"[LightSwitchService] Worker thread starting...\n"); + + // Initialize settings system + LightSwitchSettings::instance().InitFileWatcher(); + + // Open the manual override event created by the module interface + HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + + auto applyTheme = [](int nowMinutes, int lightMinutes, int darkMinutes, const auto& settings) { + bool isLightActive = false; + + if (lightMinutes < darkMinutes) + { + // Normal case: sunrise < sunset + isLightActive = (nowMinutes >= lightMinutes && nowMinutes < darkMinutes); + } + else + { + // Wraparound case: e.g. light at 21:00, dark at 06:00 + isLightActive = (nowMinutes >= lightMinutes || nowMinutes < darkMinutes); + } + + bool isSystemCurrentlyLight = GetCurrentSystemTheme(); + bool isAppsCurrentlyLight = GetCurrentAppsTheme(); + + if (isLightActive) + { + if (settings.changeSystem && !isSystemCurrentlyLight) + SetSystemTheme(true); + if (settings.changeApps && !isAppsCurrentlyLight) + SetAppsTheme(true); + } + else + { + if (settings.changeSystem && isSystemCurrentlyLight) + SetSystemTheme(false); + if (settings.changeApps && isAppsCurrentlyLight) + SetAppsTheme(false); + } + }; + + // --- At service start: immediately honor the schedule --- + { + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + LightSwitchSettings::instance().LoadSettings(); + const auto& settings = LightSwitchSettings::instance().settings(); + + applyTheme(nowMinutes, settings.lightTime + settings.sunrise_offset, settings.darkTime + settings.sunset_offset, settings); + } + + // --- Main loop: wakes once per minute or stop/parent death --- + for (;;) + { + HANDLE waits[2] = { g_ServiceStopEvent, hParent }; + DWORD count = hParent ? 2 : 1; + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + LightSwitchSettings::instance().LoadSettings(); + const auto& settings = LightSwitchSettings::instance().settings(); + + // Refresh suntimes at day boundary + if (g_lastUpdatedDay != st.wDay) + { + update_sun_times(settings); + g_lastUpdatedDay = st.wDay; + + OutputDebugString(L"[LightSwitchService] Recalculated sun times at new day boundary.\n"); + } + + wchar_t msg[160]; + swprintf_s(msg, + L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d\n", + st.wHour, + st.wMinute, + settings.lightTime / 60, + settings.lightTime % 60, + settings.darkTime / 60, + settings.darkTime % 60); + OutputDebugString(msg); + + // --- Manual override check --- + bool manualOverrideActive = false; + if (hManualOverride) + { + manualOverrideActive = (WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); + } + + if (manualOverrideActive) + { + // Did we hit a scheduled boundary? (reset override at boundary) + if (nowMinutes == (settings.lightTime + settings.sunrise_offset) % 1440 || + nowMinutes == (settings.darkTime + settings.sunset_offset) % 1440) + { + ResetEvent(hManualOverride); + OutputDebugString(L"[LightSwitchService] Manual override cleared at boundary\n"); + } + else + { + OutputDebugString(L"[LightSwitchService] Skipping schedule due to manual override\n"); + goto sleep_until_next_minute; + } + } + + // Apply theme logic (only runs if no manual override or override just cleared) + applyTheme(nowMinutes, settings.lightTime + settings.sunrise_offset, settings.darkTime + settings.sunset_offset, settings); + + sleep_until_next_minute: + GetLocalTime(&st); + int msToNextMinute = (60 - st.wSecond) * 1000 - st.wMilliseconds; + if (msToNextMinute < 50) + msToNextMinute = 50; + + DWORD wait = WaitForMultipleObjects(count, waits, FALSE, msToNextMinute); + if (wait == WAIT_OBJECT_0) // stop event + break; + if (hParent && wait == WAIT_OBJECT_0 + 1) // parent exited + break; + } + + if (hManualOverride) + CloseHandle(hManualOverride); + if (hParent) + CloseHandle(hParent); + + return 0; +} + +int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) +{ + if (powertoys_gpo::getConfiguredLightSwitchEnabledValue() == powertoys_gpo::gpo_rule_configured_disabled) + { + wchar_t msg[160]; + swprintf_s( + msg, + L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.\n"); + OutputDebugString(msg); + return 0; + } + + int argc = 0; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + int rc = _tmain(argc, argv); // reuse your existing logic + LocalFree(argv); + return rc; +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc new file mode 100644 index 0000000000..db9b9dc067 Binary files /dev/null and b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc differ diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj new file mode 100644 index 0000000000..2151d0b5b6 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -0,0 +1,219 @@ + + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + 17.0 + Win32Proj + {08e71c67-6a7e-4ca1-b04e-2fb336410bac} + LightSwitchService + 10.0.26100.0 + LightSwitchService + + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\ + PowerToys.LightSwitchService + + + + Level3 + true + _DEBUG;%(PreprocessorDefinitions) + true + + + Windows + true + + + + + Level3 + true + true + true + NDEBUG;%(PreprocessorDefinitions) + true + + + Windows + true + + + + + Level3 + true + %(PreprocessorDefinitions) + true + NotUsing + + ./../; + ..\..\..\common\Telemetry; + ..\..\..\common; + ..\..\..\; + ..\..\..\..\deps\spdlog\include; + ./; + %(AdditionalIncludeDirectories) + + + + Windows + true + Advapi32.lib;%(AdditionalDependencies) + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + {1d5be09d-78c0-4fd7-af00-ae7c1af7c525} + + + {8f021b46-362b-485c-bfba-ccf83e820cbd} + + + + + Level3 + true + true + true + NDEBUG;%(PreprocessorDefinitions) + true + + + Windows + true + + + + + NotUsing + + + + + NotUsing + + + NotUsing + + + + + + + + + + + + + + + + false + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters new file mode 100644 index 0000000000..a244dfc075 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -0,0 +1,72 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp new file mode 100644 index 0000000000..5bd5a1fe92 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp @@ -0,0 +1,167 @@ +#include "LightSwitchSettings.h" +#include +#include +#include "SettingsObserver.h" + +#include +#include +#include + +using namespace std; + +LightSwitchSettings& LightSwitchSettings::instance() +{ + static LightSwitchSettings inst; + return inst; +} + +LightSwitchSettings::LightSwitchSettings() +{ + LoadSettings(); +} + +std::wstring LightSwitchSettings::GetSettingsFileName() +{ + return PTSettingsHelper::get_module_save_file_location(L"LightSwitch"); +} + +void LightSwitchSettings::InitFileWatcher() +{ + const std::wstring& settingsFileName = GetSettingsFileName(); + m_settingsFileWatcher = std::make_unique(settingsFileName, [&]() { + PostMessageW(HWND_BROADCAST, WM_PRIV_SETTINGS_CHANGED, NULL, NULL); + }); +} + +void LightSwitchSettings::AddObserver(SettingsObserver& observer) +{ + m_observers.insert(&observer); +} + +void LightSwitchSettings::RemoveObserver(SettingsObserver& observer) +{ + m_observers.erase(&observer); +} + +void LightSwitchSettings::NotifyObservers(SettingId id) const +{ + for (auto observer : m_observers) + { + if (observer->WantsToBeNotified(id)) + { + observer->SettingsUpdate(id); + } + } +} + +void LightSwitchSettings::LoadSettings() +{ + try + { + PowerToysSettings::PowerToyValues values = + PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); + + + if (const auto jsonVal = values.get_string_value(L"scheduleMode")) + { + auto val = *jsonVal; + auto newMode = FromString(val); + if (m_settings.scheduleMode != newMode) + { + m_settings.scheduleMode = newMode; + NotifyObservers(SettingId::ScheduleMode); + } + } + + // Latitude + if (const auto jsonVal = values.get_string_value(L"latitude")) + { + auto val = *jsonVal; + if (m_settings.latitude != val) + { + m_settings.latitude = val; + NotifyObservers(SettingId::Latitude); + } + } + + // Longitude + if (const auto jsonVal = values.get_string_value(L"longitude")) + { + auto val = *jsonVal; + if (m_settings.longitude != val) + { + m_settings.longitude = val; + NotifyObservers(SettingId::Longitude); + } + } + + // LightTime + if (const auto jsonVal = values.get_int_value(L"lightTime")) + { + auto val = *jsonVal; + if (m_settings.lightTime != val) + { + m_settings.lightTime = val; + NotifyObservers(SettingId::LightTime); + } + } + + // DarkTime + if (const auto jsonVal = values.get_int_value(L"darkTime")) + { + auto val = *jsonVal; + if (m_settings.darkTime != val) + { + m_settings.darkTime = val; + NotifyObservers(SettingId::DarkTime); + } + } + + // Offset + if (const auto jsonVal = values.get_int_value(L"sunrise_offset")) + { + auto val = *jsonVal; + if (m_settings.sunrise_offset != val) + { + m_settings.sunrise_offset = val; + NotifyObservers(SettingId::Sunrise_Offset); + } + } + + if (const auto jsonVal = values.get_int_value(L"sunset_offset")) + { + auto val = *jsonVal; + if (m_settings.sunset_offset != val) + { + m_settings.sunset_offset = val; + NotifyObservers(SettingId::Sunset_Offset); + } + } + + // ChangeSystem + if (const auto jsonVal = values.get_bool_value(L"changeSystem")) + { + auto val = *jsonVal; + if (m_settings.changeSystem != val) + { + m_settings.changeSystem = val; + NotifyObservers(SettingId::ChangeSystem); + } + } + + // ChangeApps + if (const auto jsonVal = values.get_bool_value(L"changeApps")) + { + auto val = *jsonVal; + if (m_settings.changeApps != val) + { + m_settings.changeApps = val; + NotifyObservers(SettingId::ChangeApps); + } + } + } + catch (...) + { + // Keeps defaults if load fails + } +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h new file mode 100644 index 0000000000..51f0988eda --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include + +class SettingsObserver; + +enum class ScheduleMode +{ + FixedHours, + SunsetToSunrise + // Add more in the future +}; + +inline std::wstring ToString(ScheduleMode mode) +{ + switch (mode) + { + case ScheduleMode::FixedHours: + return L"FixedHours"; + case ScheduleMode::SunsetToSunrise: + return L"SunsetToSunrise"; + default: + return L"FixedHours"; + } +} + +inline ScheduleMode FromString(const std::wstring& str) +{ + if (str == L"SunsetToSunrise") + return ScheduleMode::SunsetToSunrise; + else + return ScheduleMode::FixedHours; +} + +struct LightSwitchConfig +{ + ScheduleMode scheduleMode = ScheduleMode::FixedHours; + + std::wstring latitude = L"0.0"; + std::wstring longitude = L"0.0"; + + // Stored as minutes since midnight + int lightTime = 8 * 60; // 08:00 default + int darkTime = 20 * 60; // 20:00 default + + int sunrise_offset = 0; + int sunset_offset = 0; + + bool changeSystem = false; + bool changeApps = false; +}; + +class LightSwitchSettings +{ +public: + static LightSwitchSettings& instance(); + + static inline const LightSwitchConfig& settings() + { + return instance().m_settings; + } + + void InitFileWatcher(); + static std::wstring GetSettingsFileName(); + + void AddObserver(SettingsObserver& observer); + void RemoveObserver(SettingsObserver& observer); + + void LoadSettings(); + +private: + LightSwitchSettings(); + ~LightSwitchSettings() = default; + + LightSwitchConfig m_settings; + std::unique_ptr m_settingsFileWatcher; + std::unordered_set m_observers; + + void NotifyObservers(SettingId id) const; +}; diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp new file mode 100644 index 0000000000..534e55f5e3 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp @@ -0,0 +1 @@ +#include "SettingsConstants.h" diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h new file mode 100644 index 0000000000..4872864eff --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h @@ -0,0 +1,14 @@ +#pragma once + +enum class SettingId +{ + ScheduleMode = 0, + Latitude, + Longitude, + LightTime, + DarkTime, + Sunrise_Offset, + Sunset_Offset, + ChangeSystem, + ChangeApps +}; \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h new file mode 100644 index 0000000000..88d0194eef --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include "SettingsConstants.h" + +class LightSwitchSettings; + +class SettingsObserver +{ +public: + SettingsObserver(std::unordered_set observedSettings) : + m_observedSettings(std::move(observedSettings)) + { + LightSwitchSettings::instance().AddObserver(*this); + } + + virtual ~SettingsObserver() + { + LightSwitchSettings::instance().RemoveObserver(*this); + } + + // Override this in your class to respond to updates + virtual void SettingsUpdate(SettingId type) {} + + bool WantsToBeNotified(SettingId type) const noexcept + { + return m_observedSettings.contains(type); + } + +protected: + std::unordered_set m_observedSettings; +}; diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp new file mode 100644 index 0000000000..b0a57cf468 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp @@ -0,0 +1,81 @@ +#include +#include "ThemeHelper.h" + +// Controls changing the themes. + +void SetAppsTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"AppsUseLightTheme", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +void SetSystemTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"SystemUsesLightTheme", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +// Can think of this as "is the current theme light?" +bool GetCurrentSystemTheme() +{ + HKEY hKey; + DWORD value = 1; // default = light + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"SystemUsesLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} + +bool GetCurrentAppsTheme() +{ + HKEY hKey; + DWORD value = 1; + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"AppsUseLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h new file mode 100644 index 0000000000..5985fd95c8 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h @@ -0,0 +1,5 @@ +#pragma once +void SetSystemTheme(bool dark); +void SetAppsTheme(bool dark); +bool GetCurrentSystemTheme(); +bool GetCurrentAppsTheme(); diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp new file mode 100644 index 0000000000..7b07dd0ef7 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp @@ -0,0 +1,89 @@ +#include "ThemeScheduler.h" +#include + +SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day) +{ + double zenith = 90.833; + int N1 = static_cast(floor(275.0 * month / 9.0)); + int N2 = static_cast(floor((static_cast(month) + 9) / 12.0)); + int N3 = static_cast(floor((1.0 + floor((year - 4.0 * floor(year / 4.0) + 2.0) / 3.0)))); + int N = N1 - (N2 * N3) + day - 30; + + auto calcTime = [&](bool sunrise) -> double { + double lngHour = longitude / 15.0; + double t = sunrise ? N + ((6 - lngHour) / 24) : N + ((18 - lngHour) / 24); + + double M = (0.9856 * t) - 3.289; + double L = M + (1.916 * sin(deg2rad(M))) + (0.020 * sin(2 * deg2rad(M))) + 282.634; + if (L < 0) + L += 360; + if (L > 360) + L -= 360; + + double RA = rad2deg(atan(0.91764 * tan(deg2rad(L)))); + if (RA < 0) + RA += 360; + if (RA > 360) + RA -= 360; + + double Lquadrant = floor(L / 90) * 90; + double RAquadrant = floor(RA / 90) * 90; + RA = RA + (Lquadrant - RAquadrant); + RA /= 15; + + double sinDec = 0.39782 * sin(deg2rad(L)); + double cosDec = cos(asin(sinDec)); + + double cosH = (cos(deg2rad(zenith)) - (sinDec * sin(deg2rad(latitude)))) / (cosDec * cos(deg2rad(latitude))); + if (cosH > 1 || cosH < -1) + return -1; + + double H = sunrise ? 360 - rad2deg(acos(cosH)) : rad2deg(acos(cosH)); + H /= 15; + + double T = H + RA - (0.06571 * t) - 6.622; + double UT = T - lngHour; + while (UT < 0) + UT += 24; + while (UT >= 24) + UT -= 24; + + return UT; + }; + + double riseUT = calcTime(true); + double setUT = calcTime(false); + + auto toLocal = [](double UT) { + TIME_ZONE_INFORMATION tz; + DWORD state = GetTimeZoneInformation(&tz); + double totalBias = tz.Bias; + + if (state == TIME_ZONE_ID_DAYLIGHT) + totalBias += tz.DaylightBias; + else if (state == TIME_ZONE_ID_STANDARD) + totalBias += tz.StandardBias; + + double biasHours = -(totalBias / 60.0); + double localTime = UT + biasHours; + + while (localTime < 0) + localTime += 24; + while (localTime >= 24) + localTime -= 24; + + int hour = static_cast(localTime); + int minute = static_cast((localTime - hour) * 60); + return std::pair{ hour, minute }; + }; + + auto [riseHour, riseMinute] = toLocal(riseUT); + auto [setHour, setMinute] = toLocal(setUT); + + SunTimes result; + result.sunriseHour = riseHour; + result.sunriseMinute = riseMinute; + result.sunsetHour = setHour; + result.sunsetMinute = setMinute; + return result; +} diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h new file mode 100644 index 0000000000..4e6869830a --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include + +// Struct to hold calculated sunrise/sunset times +struct SunTimes +{ + int sunriseHour; + int sunriseMinute; + int sunsetHour; + int sunsetMinute; +}; + +constexpr double PI = 3.14159265358979323846; +constexpr double deg2rad(double deg) +{ + return deg * PI / 180.0; +} +constexpr double rad2deg(double rad) +{ + return rad * 180.0 / PI; +} + +SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day); diff --git a/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp new file mode 100644 index 0000000000..5e271fc8d0 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp @@ -0,0 +1,15 @@ + +#include "WinHookEventIDs.h" +#include +#include + +UINT WM_PRIV_SETTINGS_CHANGED = 0; + +std::once_flag init_flag; + +void InitializeWinhookEventIds() +{ + std::call_once(init_flag, [&] { + WM_PRIV_SETTINGS_CHANGED = RegisterWindowMessage(L"{11978F7B-221A-4E65-B9A9-693F7D6E4B25}"); + }); +} diff --git a/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h new file mode 100644 index 0000000000..177fd139cd --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h @@ -0,0 +1,6 @@ +#pragma once +#include + +extern UINT WM_PRIV_SETTINGS_CHANGED; // Scheduled when a watched settings file is updated + +void InitializeWinhookEventIds(); \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/packages.config b/src/modules/LightSwitch/LightSwitchService/packages.config new file mode 100644 index 0000000000..ff4b059648 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/resource.h b/src/modules/LightSwitch/LightSwitchService/resource.h new file mode 100644 index 0000000000..e8ed3b4747 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by LightSwitchService.rc +// +#define IDI_ICON1 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/LockScreenLogo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/LockScreenLogo.scale-200.png new file mode 100644 index 0000000000..7440f0d4bf Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/LockScreenLogo.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/SplashScreen.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000000..32f486a867 Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/SplashScreen.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square150x150Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square150x150Logo.scale-200.png new file mode 100644 index 0000000000..53ee3777ea Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square150x150Logo.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.scale-200.png new file mode 100644 index 0000000000..f713bba67f Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000000..dc9f5bea0c Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/StoreLogo.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/StoreLogo.png new file mode 100644 index 0000000000..a4586f26bd Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/StoreLogo.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Wide310x150Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000000..8b4a5d0dd5 Binary files /dev/null and b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Wide310x150Logo.scale-200.png differ diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj b/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj new file mode 100644 index 0000000000..9770255af6 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj @@ -0,0 +1,22 @@ + + + + PowerToys.LightSwitch.UITests + LightSwitch.UITests + false + true + enable + Library + + + false + + + $(SolutionDir)$(Platform)\$(Configuration)\tests\LightSwitch.UITests\ + + + + + + + \ No newline at end of file diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest new file mode 100644 index 0000000000..a38ad92615 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest @@ -0,0 +1,51 @@ + + + + + + + + + + LightSwitch.UITests + Microsoft + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.cs new file mode 100644 index 0000000000..aaa5124995 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestGeolocation : UITestBase + { + public TestGeolocation() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.Geolocation")] + [TestCategory("Location")] + public void TestGeolocationUpdate() + { + TestHelper.InitializeTest(this, "geolocation test"); + TestHelper.PerformGeolocationTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs new file mode 100644 index 0000000000..7bb4e77af3 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs @@ -0,0 +1,435 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Win32; + +namespace LightSwitch.UITests +{ + internal sealed class TestHelper + { + private static readonly string[] ShortcutSeparators = { " + ", "+", " " }; + + /// + /// Performs common test initialization: navigate to settings, enable toggle, verify shortcut + /// + /// The test base instance + /// Name of the test for assertions + /// The activation keys for the test + public static Key[] InitializeTest(UITestBase testBase, string testName) + { + LaunchFromSetting(testBase); + + var toggleSwitch = SetLightSwitchToggle(testBase, enable: true); + Assert.IsTrue( + toggleSwitch.IsOn, + $"Light Switch toggle switch should be ON for {testName}"); + + var activationKeys = ReadActivationShortcut(testBase); + Assert.IsNotNull(activationKeys, "Should be able to read activation shortcut"); + Assert.IsTrue(activationKeys.Length > 0, "Activation shortcut should contain at least one key"); + + return activationKeys; + } + + /// + /// Navigate to the Light Switch settings page + /// + public static void LaunchFromSetting(UITestBase testBase) + { + var lightSwitch = testBase.Session.FindAll(By.AccessibilityId("LightSwitchNavItem")); + + if (lightSwitch.Count == 0) + { + testBase.Session.Find(By.AccessibilityId("SystemToolsNavItem"), 5000).Click(msPostAction: 500); + } + + testBase.Session.Find(By.AccessibilityId("LightSwitchNavItem"), 5000).Click(msPostAction: 500); + } + + /// + /// Set the Light Switch enable toggle switch to the specified state + /// + public static ToggleSwitch SetLightSwitchToggle(UITestBase testBase, bool enable) + { + var toggleSwitch = testBase.Session.Find(By.AccessibilityId("Toggle_LightSwitch"), 5000); + + if (toggleSwitch.IsOn != enable) + { + toggleSwitch.Click(msPreAction: 1000, msPostAction: 2000); + } + + if (toggleSwitch.IsOn != enable) + { + testBase.Session.SendKey(Key.Space, msPreAction: 0, msPostAction: 2000); + } + + return toggleSwitch; + } + + /// + /// Read the current activation shortcut from the ShortcutControl + /// + public static Key[] ReadActivationShortcut(UITestBase testBase) + { + var shortcutCard = testBase.Session.Find(By.AccessibilityId("Shortcut_LightSwitch"), 5000); + var shortcutButton = shortcutCard.Find(By.AccessibilityId("EditButton"), 5000); + return ParseShortcutText(shortcutButton.HelpText); + } + + /// + /// Parse shortcut text like "Win + Ctrl + Shift + M" into Key array + /// + private static Key[] ParseShortcutText(string shortcutText) + { + if (string.IsNullOrEmpty(shortcutText)) + { + return new Key[] { Key.Win, Key.Ctrl, Key.Shift, Key.D }; + } + + var keys = new List(); + var parts = shortcutText.Split(ShortcutSeparators, StringSplitOptions.RemoveEmptyEntries); + + foreach (var part in parts) + { + var cleanPart = part.Trim().ToLowerInvariant(); + var key = cleanPart switch + { + "win" or "windows" => Key.Win, + "ctrl" or "control" => Key.Ctrl, + "shift" => Key.Shift, + "alt" => Key.Alt, + _ when cleanPart.Length == 1 && char.IsLetter(cleanPart[0]) && + cleanPart[0] >= 'a' && cleanPart[0] <= 'z' => + (Key)Enum.Parse(typeof(Key), cleanPart.ToUpperInvariant()), + _ => (Key?)null, + }; + + if (key.HasValue) + { + keys.Add(key.Value); + } + } + + return keys.Count > 0 ? keys.ToArray() : new Key[] { Key.Win, Key.Ctrl, Key.Shift, Key.D }; + } + + /// + /// Performs common test cleanup: close LightSwitch task + /// + /// The test base instance + public static void CleanupTest(UITestBase testBase) + { + // TODO: Make sure the task kills? + // CloseLightSwitch(testBase); + + // Ensure we're attached to settings after cleanup + try + { + testBase.Session.Attach(PowerToysModule.PowerToysSettings); + } + catch + { + // Ignore attachment errors - this is just cleanup + } + } + + /// + /// Perform a update time test operation + /// + public static void PerformUpdateTimeTest(UITestBase testBase) + { + // Make sure in manual mode + var modeCombobox = testBase.Session.Find(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + var neededTabs = 5; + + if (modeCombobox.Text != "Manual") + { + modeCombobox.Click(); + var manualListItem = testBase.Session.Find(By.AccessibilityId("ManualCBItem_LightSwitch"), 5000); + Assert.IsNotNull(manualListItem, "Manual combobox item not found."); + manualListItem.Click(); + neededTabs = 1; + } + + Assert.AreEqual("Manual", modeCombobox.Text, "Mode combobox should be set to Manual."); + + var timeline = testBase.Session.Find(By.AccessibilityId("Timeline_LightSwitch"), 5000); + Assert.IsNotNull(timeline, "Timeline not found."); + + var helpText = timeline.GetAttribute("HelpText"); + string originalStartValue = GetHelpTextValue(helpText, "Start"); + + for (int i = 0; i < neededTabs; i++) + { + testBase.Session.SendKeys(Key.Tab); + } + + testBase.Session.SendKeys(Key.Enter); + testBase.Session.SendKeys(Key.Up); + testBase.Session.SendKeys(Key.Enter); + + helpText = timeline.GetAttribute("HelpText"); + string updatedStartValue = GetHelpTextValue(helpText, "Start"); + + Assert.AreNotEqual(originalStartValue, updatedStartValue, "Timeline start time should have been updated."); + + helpText = timeline.GetAttribute("HelpText"); + string originalEndValue = GetHelpTextValue(helpText, "End"); + + testBase.Session.SendKeys(Key.Tab); + testBase.Session.SendKeys(Key.Enter); + testBase.Session.SendKeys(Key.Up); + testBase.Session.SendKeys(Key.Enter); + + helpText = timeline.GetAttribute("HelpText"); + string updatedEndValue = GetHelpTextValue(helpText, "End"); + + Assert.AreNotEqual(originalEndValue, updatedEndValue, "Timeline end time should have been updated."); + } + + /// + /// Perform a update geolocation test operation + /// + public static void PerformUserSelectedLocationTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + var setLocationButton = testBase.Session.Find(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(setLocationButton, "Set location button not found."); + setLocationButton.Click(); + + var autoSuggestTextbox = testBase.Session.Find(By.AccessibilityId("CitySearchBox_LightSwitch"), 5000); + Assert.IsNotNull(autoSuggestTextbox, "City search box not found."); + autoSuggestTextbox.Click(); + autoSuggestTextbox.SendKeys("Seattle"); + autoSuggestTextbox.SendKeys(OpenQA.Selenium.Keys.Down); + autoSuggestTextbox.SendKeys(OpenQA.Selenium.Keys.Enter); + + var latLong = testBase.Session.Find(By.AccessibilityId("LocationResultText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(latLong.Text)); + + var sunrise = testBase.Session.Find(By.AccessibilityId("SunriseText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunrise.Text)); + + var sunset = testBase.Session.Find(By.AccessibilityId("SunsetText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunset.Text)); + } + + /// + /// Perform a update geolocation test operation + /// + public static void PerformGeolocationTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + // Click the select city button + var setLocationButton = testBase.Session.Find(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(setLocationButton, "Set location button not found."); + setLocationButton.Click(); + + var syncLocationButton = testBase.Session.Find(By.AccessibilityId("SyncLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(syncLocationButton, "Sync location button not found."); + syncLocationButton.Click(msPostAction: 8000); + + var latLong = testBase.Session.Find(By.AccessibilityId("LocationResultText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(latLong.Text)); + + var sunrise = testBase.Session.Find(By.AccessibilityId("SunriseText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunrise.Text)); + + var sunset = testBase.Session.Find(By.AccessibilityId("SunsetText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunset.Text)); + } + + /// + /// Perform a update time test operation + /// + public static void PerformOffsetTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + // Testing sunrise offset + var sunriseOffset = testBase.Session.Find(By.AccessibilityId("SunriseOffset_LightSwitch"), 5000); + Assert.IsNotNull(sunriseOffset, "Sunrise offset number box not found."); + + var timeline = testBase.Session.Find(By.AccessibilityId("Timeline_LightSwitch"), 5000); + Assert.IsNotNull(timeline, "Timeline not found."); + + var helpText = timeline.GetAttribute("HelpText"); + string originalStartValue = GetHelpTextValue(helpText, "Start"); + + sunriseOffset.Click(); + + helpText = timeline.GetAttribute("HelpText"); + string updatedStartValue = GetHelpTextValue(helpText, "Start"); + + Assert.AreNotEqual(originalStartValue, updatedStartValue, "Timeline start time should have been updated."); + + // Testing sunset offset + var sunsetOffset = testBase.Session.Find(By.AccessibilityId("SunsetOffset_LightSwitch"), 5000); + Assert.IsNotNull(sunsetOffset, "Sunrise offset number box not found."); + + helpText = timeline.GetAttribute("HelpText"); + string originalEndValue = GetHelpTextValue(helpText, "End"); + + sunsetOffset.Click(); + + helpText = timeline.GetAttribute("HelpText"); + string updatedEndValue = GetHelpTextValue(helpText, "End"); + + Assert.AreNotEqual(originalEndValue, updatedEndValue, "Timeline end time should have been updated."); + } + + /// + /// Perform a test for shortcut changing themes + /// + public static void PerformShortcutTest(UITestBase testBase, Key[] activationKeys) + { + // Test when both are checked + var systemCheckbox = testBase.Session.Find(By.AccessibilityId("ChangeSystemCheckbox_LightSwitch"), 5000); + Assert.IsNotNull(systemCheckbox, "System checkbox not found."); + + var scrollViewer = testBase.Session.Find(By.AccessibilityId("PageScrollViewer")); + systemCheckbox.EnsureVisible(scrollViewer); + + // How do I handle when something is off screen? + if (!systemCheckbox.Selected) + { + systemCheckbox.Click(); + } + + Assert.IsTrue(systemCheckbox.Selected, "System checkbox should be checked."); + + var appsCheckbox = testBase.Session.Find(By.AccessibilityId("ChangeAppsCheckbox_LightSwitch"), 5000); + Assert.IsNotNull(appsCheckbox, "Apps checkbox not found."); + + if (!appsCheckbox.Selected) + { + appsCheckbox.Click(); + } + + Assert.IsTrue(appsCheckbox.Selected, "Apps checkbox should be checked."); + + var systemBeforeValue = GetSystemTheme(); + var appsBeforeValue = GetAppsTheme(); + + testBase.Session.SendKeys(activationKeys); + Task.Delay(5000).Wait(); + + var systemAfterValue = GetSystemTheme(); + var appsAfterValue = GetAppsTheme(); + + Assert.AreNotEqual(systemBeforeValue, systemAfterValue, "System theme should have changed."); + Assert.AreNotEqual(appsBeforeValue, appsAfterValue, "Apps theme should have changed."); + + // Test with nothing checked + if (systemCheckbox.Selected) + { + systemCheckbox.Click(); + } + + if (appsCheckbox.Selected) + { + appsCheckbox.Click(); + } + + Assert.IsFalse(systemCheckbox.Selected, "System checkbox should be unchecked."); + Assert.IsFalse(appsCheckbox.Selected, "Apps checkbox should be unchecked."); + + var noneSystemBeforeValue = GetSystemTheme(); + var noneAppsBeforeValue = GetAppsTheme(); + + testBase.Session.SendKeys(activationKeys); + Task.Delay(5000).Wait(); + + var noneSystemAfterValue = GetSystemTheme(); + var noneAppsAfterValue = GetAppsTheme(); + + Assert.AreEqual(noneSystemBeforeValue, noneSystemAfterValue, "System theme should not have changed."); + Assert.AreEqual(noneAppsBeforeValue, noneAppsAfterValue, "Apps theme should not have changed."); + } + + /* Helpers */ + private static int GetSystemTheme() + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + if (key is null) + { + return 1; + } + + return (int)key.GetValue("SystemUsesLightTheme", 1); + } + + private static int GetAppsTheme() + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + if (key is null) + { + return 1; + } + + return (int)key.GetValue("AppsUseLightTheme", 1); + } + + private static string GetHelpTextValue(string helpText, string key) + { + foreach (var part in helpText.Split(';')) + { + var kv = part.Split('='); + if (kv.Length == 2 && kv[0] == key) + { + return kv[1]; + } + } + + return string.Empty; + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.cs new file mode 100644 index 0000000000..e8ed9debf6 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestOffset : UITestBase + { + public TestOffset() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.Offset")] + [TestCategory("Time")] + public void TestTimeOffset() + { + TestHelper.InitializeTest(this, "offset test"); + TestHelper.PerformOffsetTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.cs new file mode 100644 index 0000000000..26e17c4612 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestShortcut : UITestBase + { + public TestShortcut() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.TestShortcut")] + [TestCategory("Shortcut")] + public void TestLightSwitchShortcut() + { + var activationKeys = TestHelper.InitializeTest(this, "light switch shortcut test"); + TestHelper.PerformShortcutTest(this, activationKeys); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.cs new file mode 100644 index 0000000000..f92909657f --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestUpdateManualTime : UITestBase + { + public TestUpdateManualTime() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.UpdateManualTime")] + [TestCategory("Time")] + public void TestUpdateTime() + { + TestHelper.InitializeTest(this, "update manual time test"); + TestHelper.PerformUpdateTimeTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.cs new file mode 100644 index 0000000000..924a04d9d9 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestUserSelectedLocation : UITestBase + { + public TestUserSelectedLocation() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.UserSelectedLocation")] + [TestCategory("Location")] + public void TestUserSelectedLocationUpdate() + { + TestHelper.InitializeTest(this, "user selected location test"); + TestHelper.PerformUserSelectedLocationTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest b/src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest new file mode 100644 index 0000000000..0cec0ecb5e --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + PerMonitorV2 + + + \ No newline at end of file diff --git a/src/runner/main.cpp b/src/runner/main.cpp index cc8e6ca2b0..4b29149f78 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -177,6 +177,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.WorkspacesModuleInterface.dll", L"PowerToys.CmdPalModuleInterface.dll", L"PowerToys.ZoomItModuleInterface.dll", + L"PowerToys.LightSwitchModuleInterface.dll", }; for (auto moduleSubdir : knownModules) diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index b3ced3b858..473fa7ebe3 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -757,6 +757,8 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) return "ColorPicker"; case ESettingsWindowNames::CmdNotFound: return "CmdNotFound"; + case ESettingsWindowNames::LightSwitch: + return "LightSwitch"; case ESettingsWindowNames::FancyZones: return "FancyZones"; case ESettingsWindowNames::FileLocksmith: @@ -842,6 +844,10 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::CmdNotFound; } + else if (value == "LightSwitch") + { + return ESettingsWindowNames::LightSwitch; + } else if (value == "FancyZones") { return ESettingsWindowNames::FancyZones; diff --git a/src/runner/settings_window.h b/src/runner/settings_window.h index 611e24233e..e15108059f 100644 --- a/src/runner/settings_window.h +++ b/src/runner/settings_window.h @@ -10,6 +10,7 @@ enum class ESettingsWindowNames Awake, ColorPicker, CmdNotFound, + LightSwitch, FancyZones, FileLocksmith, Run, diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index 0e0556d442..977c03b839 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -513,6 +513,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool lightSwitch; + + [JsonPropertyName("LightSwitch")] + public bool LightSwitch + { + get => lightSwitch; + set + { + if (lightSwitch != value) + { + LogTelemetryEvent(value); + lightSwitch = value; + NotifyChange(); + } + } + } + private void NotifyChange() { notifyEnabledChangedAction?.Invoke(); diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.cs b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.cs new file mode 100644 index 0000000000..8e357534af --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Settings.UI.Library.Helpers +{ + public class SearchLocation + { + public string City { get; set; } + + public string Country { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public SearchLocation(string city, string country, double latitude, double longitude) + { + City = city; + Country = country; + Latitude = latitude; + Longitude = longitude; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs new file mode 100644 index 0000000000..24f0846a02 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + public static class SearchLocationLoader + { + private static readonly List LocationDataList = new List(); + + public static IEnumerable GetAll() + { + return LocationDataList + .GroupBy(l => $"{l.Country}|{l.City}|{l.Latitude.ToString(CultureInfo.InvariantCulture)}|{l.Longitude.ToString(CultureInfo.InvariantCulture)}") + .Select(g => g.First()) + .OrderBy(l => l.Country, StringComparer.OrdinalIgnoreCase) + .ThenBy(l => l.City, StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs b/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs new file mode 100644 index 0000000000..6b69fee755 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public static class SunCalc + { + public static SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day) + { + double zenith = 90.833; // official sunrise/sunset + + int n1 = (int)Math.Floor(275.0 * month / 9.0); + int n2 = (int)Math.Floor((month + 9.0) / 12.0); + int n3 = (int)Math.Floor(1.0 + Math.Floor((year - (4.0 * Math.Floor(year / 4.0)) + 2.0) / 3.0)); + int n = n1 - (n2 * n3) + day - 30; + + double? riseUT = CalcTime(isSunrise: true); + double? setUT = CalcTime(isSunrise: false); + + var riseLocal = ToLocal(riseUT, year, month, day); + var setLocal = ToLocal(setUT, year, month, day); + + var result = new SunTimes + { + HasSunrise = riseLocal.HasValue, + HasSunset = setLocal.HasValue, + SunriseHour = riseLocal?.Hour ?? -1, + SunriseMinute = riseLocal?.Minute ?? -1, + SunsetHour = setLocal?.Hour ?? -1, + SunsetMinute = setLocal?.Minute ?? -1, + }; + + return result; + + // Local functions + double? CalcTime(bool isSunrise) + { + double lngHour = longitude / 15.0; + double t = isSunrise ? n + ((6 - lngHour) / 24.0) : n + ((18 - lngHour) / 24.0); + + double m1 = (0.9856 * t) - 3.289; + double l = m1 + (1.916 * Math.Sin(Deg2Rad(m1))) + (0.020 * Math.Sin(2 * Deg2Rad(m1))) + 282.634; + l = NormalizeDegrees(l); + + double rA = Rad2Deg(Math.Atan(0.91764 * Math.Tan(Deg2Rad(l)))); + rA = NormalizeDegrees(rA); + + double lquadrant = Math.Floor(l / 90.0) * 90.0; + double rAquadrant = Math.Floor(rA / 90.0) * 90.0; + rA = rA + (lquadrant - rAquadrant); + rA /= 15.0; + + double sinDec = 0.39782 * Math.Sin(Deg2Rad(l)); + double cosDec = Math.Cos(Math.Asin(sinDec)); + + double cosH = (Math.Cos(Deg2Rad(zenith)) - (sinDec * Math.Sin(Deg2Rad(latitude)))) + / (cosDec * Math.Cos(Deg2Rad(latitude))); + + if (cosH > 1.0 || cosH < -1.0) + { + // Sun never rises or never sets on this date at this location + return null; + } + + double h = isSunrise ? 360.0 - Rad2Deg(Math.Acos(cosH)) : Rad2Deg(Math.Acos(cosH)); + h /= 15.0; + + double t1 = h + rA - (0.06571 * t) - 6.622; + double uT = t1 - lngHour; + uT = NormalizeHours(uT); + + return uT; + } + + static (int Hour, int Minute)? ToLocal(double? ut, int y, int m, int d) + { + if (!ut.HasValue) + { + return null; + } + + // Convert fractional hours to hh:mm with proper rounding + int hours = (int)Math.Floor(ut.Value); + int minutes = (int)((ut.Value - hours) * 60.0); + + // Normalize minute overflow + if (minutes == 60) + { + minutes = 0; + hours = (hours + 1) % 24; + } + + // Build a UTC DateTime on the given date + var utc = new DateTime(y, m, d, hours, minutes, 0, DateTimeKind.Utc); + + // Convert to local time using system time zone rules for that date + var local = TimeZoneInfo.ConvertTimeFromUtc(utc, TimeZoneInfo.Local); + + return (local.Hour, local.Minute); + } + + static double Deg2Rad(double deg) => deg * Math.PI / 180.0; + static double Rad2Deg(double rad) => rad * 180.0 / Math.PI; + + static double NormalizeDegrees(double angle) + { + angle %= 360.0; + if (angle < 0) + { + angle += 360.0; + } + + return angle; + } + + static double NormalizeHours(double hours) + { + hours %= 24.0; + if (hours < 0) + { + hours += 24.0; + } + + return hours; + } + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.cs b/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.cs new file mode 100644 index 0000000000..2f4f31fc57 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public struct SunTimes + { + public int SunriseHour; + public int SunriseMinute; + public int SunsetHour; + public int SunsetMinute; + public string Text; + + public bool HasSunrise; + public bool HasSunset; + } +} diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs new file mode 100644 index 0000000000..a58022d4a6 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class LightSwitchProperties + { + public const bool DefaultChangeSystem = true; + public const bool DefaultChangeApps = true; + public const int DefaultLightTime = 480; + public const int DefaultDarkTime = 1200; + public const int DefaultSunriseOffset = 0; + public const int DefaultSunsetOffset = 0; + public const string DefaultLatitude = "0.0"; + public const string DefaultLongitude = "0.0"; + public const string DefaultScheduleMode = "FixedHours"; + public static readonly HotkeySettings DefaultToggleThemeHotkey = new HotkeySettings(true, true, false, true, 0x44); // Ctrl+Win+Shift+D + + public LightSwitchProperties() + { + ChangeSystem = new BoolProperty(DefaultChangeSystem); + ChangeApps = new BoolProperty(DefaultChangeApps); + LightTime = new IntProperty(DefaultLightTime); + DarkTime = new IntProperty(DefaultDarkTime); + Latitude = new StringProperty(DefaultLatitude); + Longitude = new StringProperty(DefaultLongitude); + SunriseOffset = new IntProperty(DefaultSunriseOffset); + SunsetOffset = new IntProperty(DefaultSunsetOffset); + ScheduleMode = new StringProperty(DefaultScheduleMode); + ToggleThemeHotkey = new KeyboardKeysProperty(DefaultToggleThemeHotkey); + } + + [JsonPropertyName("changeSystem")] + public BoolProperty ChangeSystem { get; set; } + + [JsonPropertyName("changeApps")] + public BoolProperty ChangeApps { get; set; } + + [JsonPropertyName("lightTime")] + public IntProperty LightTime { get; set; } + + [JsonPropertyName("darkTime")] + public IntProperty DarkTime { get; set; } + + [JsonPropertyName("sunrise_offset")] + public IntProperty SunriseOffset { get; set; } + + [JsonPropertyName("sunset_offset")] + public IntProperty SunsetOffset { get; set; } + + [JsonPropertyName("latitude")] + public StringProperty Latitude { get; set; } + + [JsonPropertyName("longitude")] + public StringProperty Longitude { get; set; } + + [JsonPropertyName("scheduleMode")] + public StringProperty ScheduleMode { get; set; } + + [JsonPropertyName("toggle-theme-hotkey")] + public KeyboardKeysProperty ToggleThemeHotkey { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs new file mode 100644 index 0000000000..3dac744762 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reflection; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Settings.UI.Library +{ + public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable + { + public const string ModuleName = "LightSwitch"; + + public LightSwitchSettings() + { + Name = ModuleName; + Version = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + Properties = new LightSwitchProperties(); + } + + [JsonPropertyName("properties")] + public LightSwitchProperties Properties { get; set; } + + public object Clone() + { + return new LightSwitchSettings() + { + Name = Name, + Version = Version, + Properties = new LightSwitchProperties() + { + ChangeSystem = new BoolProperty(Properties.ChangeSystem.Value), + ChangeApps = new BoolProperty(Properties.ChangeApps.Value), + ScheduleMode = new StringProperty(Properties.ScheduleMode.Value), + LightTime = new IntProperty((int)Properties.LightTime.Value), + DarkTime = new IntProperty((int)Properties.DarkTime.Value), + SunriseOffset = new IntProperty((int)Properties.SunriseOffset.Value), + SunsetOffset = new IntProperty((int)Properties.SunsetOffset.Value), + Latitude = new StringProperty(Properties.Latitude.Value), + Longitude = new StringProperty(Properties.Longitude.Value), + }, + }; + } + + public string GetModuleName() + { + return Name; + } + + public bool UpgradeSettingsConfiguration() + { + return false; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs b/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs new file mode 100644 index 0000000000..814ab5a6b1 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class SndLightSwitchSettings + { + [JsonPropertyName("LightSwitch")] + public LightSwitchSettings Settings { get; set; } + + public SndLightSwitchSettings() + { + } + + public SndLightSwitchSettings(LightSwitchSettings settings) + { + Settings = settings; + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png new file mode 100644 index 0000000000..d4ce00c74a Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png new file mode 100644 index 0000000000..3a98b7f3e2 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png new file mode 100644 index 0000000000..1532531a86 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Converters/EnumToVisibilityConverter.cs b/src/settings-ui/Settings.UI/Converters/EnumToVisibilityConverter.cs new file mode 100644 index 0000000000..5cb7a5e0ed --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/EnumToVisibilityConverter.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class EnumToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value == null || parameter == null) + { + return Visibility.Collapsed; + } + + string enumString = value.ToString(); + string targetString = parameter.ToString(); + + return enumString.Equals(targetString, StringComparison.OrdinalIgnoreCase) + ? Visibility.Visible + : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs b/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs new file mode 100644 index 0000000000..496c96959b --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public sealed partial class TimeSpanToFriendlyTimeConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is TimeSpan time) + { + return TimeSpanHelper.Convert(time); + } + + return string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => new NotImplementedException(); +} diff --git a/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs index f21ca2bfac..b33ac264ed 100644 --- a/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs @@ -52,6 +52,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.CmdPal: return generalSettingsConfig.Enabled.CmdPal; case ModuleType.ColorPicker: return generalSettingsConfig.Enabled.ColorPicker; case ModuleType.CropAndLock: return generalSettingsConfig.Enabled.CropAndLock; + case ModuleType.LightSwitch: return generalSettingsConfig.Enabled.LightSwitch; case ModuleType.EnvironmentVariables: return generalSettingsConfig.Enabled.EnvironmentVariables; case ModuleType.FancyZones: return generalSettingsConfig.Enabled.FancyZones; case ModuleType.FileLocksmith: return generalSettingsConfig.Enabled.FileLocksmith; @@ -88,6 +89,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.CmdPal: generalSettingsConfig.Enabled.CmdPal = isEnabled; break; case ModuleType.ColorPicker: generalSettingsConfig.Enabled.ColorPicker = isEnabled; break; case ModuleType.CropAndLock: generalSettingsConfig.Enabled.CropAndLock = isEnabled; break; + case ModuleType.LightSwitch: generalSettingsConfig.Enabled.LightSwitch = isEnabled; break; case ModuleType.EnvironmentVariables: generalSettingsConfig.Enabled.EnvironmentVariables = isEnabled; break; case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break; case ModuleType.FileLocksmith: generalSettingsConfig.Enabled.FileLocksmith = isEnabled; break; @@ -159,6 +161,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers ModuleType.CmdPal => typeof(CmdPalPage), ModuleType.ColorPicker => typeof(ColorPickerPage), ModuleType.CropAndLock => typeof(CropAndLockPage), + ModuleType.LightSwitch => typeof(LightSwitchPage), ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage), ModuleType.FancyZones => typeof(FancyZonesPage), ModuleType.FileLocksmith => typeof(FileLocksmithPage), diff --git a/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs b/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs new file mode 100644 index 0000000000..95308ec67e --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Helpers; + +public static class TimeSpanHelper +{ + public static string Convert(TimeSpan? time) + { + if (time is not TimeSpan ts) + { + return string.Empty; + } + + // If user passed in a negative TimeSpan, normalize + if (ts < TimeSpan.Zero) + { + ts = ts.Duration(); + } + + // Map the TimeSpan to a DateTime on today's date + var dt = DateTime.Today.Add(ts); + + // This pattern automatically respects system 12/24-hour setting + string pattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern; + + return dt.ToString(pattern, CultureInfo.CurrentCulture); + } +} diff --git a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs index fbf689b9de..fd684168b0 100644 --- a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs +++ b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs @@ -20,6 +20,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums FileExplorer, ImageResizer, KBM, + LightSwitch, MouseUtils, MouseWithoutBorders, Peek, diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index eac4332e27..dd70af7533 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -22,6 +22,7 @@ + @@ -156,7 +157,9 @@ Always - + + + MSBuild:Compile MSBuild:Compile @@ -170,6 +173,12 @@ MSBuild:Compile + + MSBuild:Compile + + + MSBuild:Compile + diff --git a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs index 8a25055620..838149a04e 100644 --- a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs +++ b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs @@ -10,6 +10,7 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; +using Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.SerializationContext; @@ -22,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext; [JsonSerializable(typeof(FileLocksmithSettings))] [JsonSerializable(typeof(FindMyMouseSettings))] [JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(LightSwitchSettings))] [JsonSerializable(typeof(MeasureToolSettings))] [JsonSerializable(typeof(MouseHighlighterSettings))] [JsonSerializable(typeof(MouseJumpSettings))] diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml index d4a95313d0..f63ccdf3a6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml @@ -17,6 +17,7 @@ + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index d5bd0977e1..19cd75b022 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -417,6 +417,7 @@ namespace Microsoft.PowerToys.Settings.UI case "Awake": return typeof(AwakePage); case "CmdNotFound": return typeof(CmdNotFoundPage); case "ColorPicker": return typeof(ColorPickerPage); + case "LightSwitch": return typeof(LightSwitchPage); case "FancyZones": return typeof(FancyZonesPage); case "FileLocksmith": return typeof(FileLocksmithPage); case "Run": return typeof(PowerLauncherPage); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml index 7e47cf6a97..e60c8ad400 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml @@ -28,7 +28,7 @@ Style="{StaticResource TitleTextBlockStyle}" Text="{x:Bind ModuleTitle}" /> - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs new file mode 100644 index 0000000000..189556ee84 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs @@ -0,0 +1,658 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Shapes; +using Windows.Foundation; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class Timeline : UserControl + { + public TimeSpan StartTime + { + get => (TimeSpan)GetValue(StartTimeProperty); + set => SetValue(StartTimeProperty, value); + } + + public static readonly DependencyProperty StartTimeProperty = DependencyProperty.Register(nameof(StartTime), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: new TimeSpan(22, 0, 0), OnTimeChanged)); + + public TimeSpan EndTime + { + get => (TimeSpan)GetValue(EndTimeProperty); + set => SetValue(EndTimeProperty, value); + } + + public static readonly DependencyProperty EndTimeProperty = DependencyProperty.Register(nameof(EndTime), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: new TimeSpan(7, 0, 0), OnTimeChanged)); + + public TimeSpan? Sunrise + { + get => (TimeSpan?)GetValue(SunriseProperty); + set => SetValue(SunriseProperty, value); + } + + public static readonly DependencyProperty SunriseProperty = DependencyProperty.Register(nameof(Sunrise), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: null, OnTimeChanged)); + + public TimeSpan? Sunset + { + get => (TimeSpan?)GetValue(SunsetProperty); + set => SetValue(SunsetProperty, value); + } + + public static readonly DependencyProperty SunsetProperty = DependencyProperty.Register(nameof(Sunset), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: null, OnTimeChanged)); + + private readonly List _tickHours = new(); + + // Locale 24h/12h + private readonly bool _is24h = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains('H'); + + // Visuals + private readonly List _ticks = new(); + private readonly List _majorTickBottomLabels = new(); // 00,06,12,18,24 (below) + + private readonly List _darkRects = new(); // up to 2 (wrap) + private readonly List _lightRects = new(); // up to 2 (complement) + + private TextBlock _startEdgeLabel; // top-of-chart + private TextBlock _endEdgeLabel; + + private Line _sunriseTick; + private Line _sunsetTick; + + // Add/replace these constants (top of your class) + private const int TickHourStep = 2; // <-- every 2 hours + + private StackPanel _sunrisePanel; // icon + time (below chart) + private StackPanel _sunsetPanel; + + public Timeline() + { + this.InitializeComponent(); + this.Loaded += Timeline_Loaded; + this.IsEnabledChanged += Timeline_IsEnabledChanged; + } + + private void Timeline_Loaded(object sender, RoutedEventArgs e) + { + CheckEnabledState(); + } + + private void Timeline_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + CheckEnabledState(); + } + + private void CheckEnabledState() + { + if (IsEnabled) + { + this.Opacity = 1.0; + } + else + { + this.Opacity = 0.4; + } + } + + private static void OnTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((Timeline)d).Setup(); + } + + private void Setup() + { + EnsureBands(); + EnsureTicks(); + EnsureStartEndEdgeLabels(); + EnsureSunriseSunsetTicks(); + EnsureSunPanels(); + EnsureMajorTickLabels(); + UpdateAll(); + } + + private void TimelineCanvas_Loaded(object sender, RoutedEventArgs e) + { + // SizeChanged wiring here (as requested) + HeaderCanvas.SizeChanged += (_, __) => UpdateAll(); + TimelineCanvas.SizeChanged += (_, __) => UpdateAll(); + AnnotationCanvas.SizeChanged += (_, __) => UpdateAll(); + Setup(); + } + + private void UpdateAll() + { + UpdateBandsLayout(); + UpdateTicksLayout(); + UpdateStartEndEdgeLabelsLayout(); + UpdateSunriseSunsetTicksLayout(); + UpdateSunPanelsLayout(); + UpdateMajorTickLabelsLayout(); + AutomationProperties.SetHelpText( + this, + $"Start={StartTime};End={EndTime};Sunrise={Sunrise};Sunset={Sunset}"); + } + + // ===== Ticks ===== + private void EnsureTicks() + { + if (_ticks.Count > 0) + { + return; + } + + _tickHours.Clear(); + + // Build ticks at 0,2,4,...,24 but skip the first/last MAJOR ticks (0 and 24) + for (int hour = 0; hour <= 24; hour += TickHourStep) + { + bool isMajor = hour % 6 == 0; + if (isMajor && (hour == 0 || hour == 24)) + { + continue; // skip first/last major ticks + } + + var line = new Line + { + Style = (Style)Application.Current.Resources[isMajor ? "MajorHourTickStyle" : "HourTickStyle"], + }; + + Canvas.SetZIndex(line, 0); // above bands (adjust if needed) + + _ticks.Add(line); + _tickHours.Add(hour); + + // If you actually want these IN the chart, use TimelineCanvas instead: + AnnotationCanvas.Children.Add(line); // or TimelineCanvas.Children.Add(line); + } + } + + private void UpdateTicksLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight; // keeping your offset + if (w <= 0 || h <= 0) + { + return; + } + + double minorLen = h * 0.1; + double majorLen = h * 0.2; + + for (int i = 0; i < _ticks.Count; i++) + { + int hour = _tickHours[i]; + double x = Math.Round((hour / 24.0) * w); + + var line = _ticks[i]; + double len = (hour % 6 == 0) ? majorLen : minorLen; + + line.X1 = x; + line.Y1 = 0; + line.X2 = x; + line.Y2 = len; + } + } + + // ===== Bands (Dark + Light) ===== + private void EnsureBands() + { + if (_darkRects.Count == 0) + { + _darkRects.Add(MakeBandRect(isDark: false)); + _darkRects.Add(MakeBandRect(isDark: false)); + } + + if (_lightRects.Count == 0) + { + _lightRects.Add(MakeBandRect(isDark: true)); + _lightRects.Add(MakeBandRect(isDark: true)); + } + } + + private Border MakeBandRect(bool isDark) + { + var r = new Border(); + if (isDark) + { + r.Style = (Style)Application.Current.Resources["DarkBandStyle"]; + FontIcon icon = new FontIcon(); + icon.Style = (Style)Application.Current.Resources["DarkBandIconStyle"]; + r.Child = icon; + } + else + { + r.Style = (Style)Application.Current.Resources["LightBandStyle"]; + } + + Canvas.SetZIndex(r, 5); // below ticks/labels + TimelineCanvas.Children.Add(r); + return r; + } + + private void UpdateBandsLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight; + if (w <= 0 || h <= 0) + { + return; + } + + foreach (var r in _darkRects) + { + r.Height = h; + Canvas.SetTop(r, 0); + } + + foreach (var r in _lightRects) + { + r.Height = h; + Canvas.SetTop(r, 0); + } + + var darkRanges = ToRanges(StartTime, EndTime); // 1 or 2 segments + var lightRanges = ComplementRanges(darkRanges); // 0..2 + + LayoutRangeRects(_darkRects, darkRanges, w); + LayoutRangeRects(_lightRects, lightRanges, w); + } + + private static void LayoutRangeRects(List rects, List<(TimeSpan Start, TimeSpan End)> ranges, double width) + { + for (int i = 0; i < rects.Count; i++) + { + if (i < ranges.Count) + { + var (start, end) = ranges[i]; + double x = Math.Round((start.TotalHours / 24.0) * width); + double x2 = Math.Round((end.TotalHours / 24.0) * width); + + var r = rects[i]; + Canvas.SetLeft(r, x); + r.Width = Math.Max(0, x2 - x); + r.Visibility = Visibility.Visible; + } + else + { + rects[i].Visibility = Visibility.Collapsed; + } + } + } + + private static List<(TimeSpan Start, TimeSpan End)> ToRanges(TimeSpan start, TimeSpan end) + { + // Full day + if (start == end) + { + return new() { (TimeSpan.Zero, TimeSpan.FromHours(24)) }; + } + + if (start < end) + { + return new() { (start, end) }; + } + + // Wraps midnight + return new() + { + (start, TimeSpan.FromHours(24)), + (TimeSpan.Zero, end), + }; + } + + private static List<(TimeSpan Start, TimeSpan End)> ComplementRanges(List<(TimeSpan Start, TimeSpan End)> dark) + { + var res = new List<(TimeSpan, TimeSpan)>(); + + // If dark covers the full day, there is no light + if (dark.Count == 1 && dark[0].Start == TimeSpan.Zero && dark[0].End == TimeSpan.FromHours(24)) + { + return res; + } + + if (dark.Count == 1) + { + var (ds, de) = dark[0]; + if (ds > TimeSpan.Zero) + { + res.Add((TimeSpan.Zero, ds)); + } + + if (de < TimeSpan.FromHours(24)) + { + res.Add((de, TimeSpan.FromHours(24))); + } + } + else + { + // dark[0] = [a,24), dark[1] = [0,b) => single light [b,a) + var a = dark[0].Start; + var b = dark[1].End; + res.Add((b, a)); + } + + return res; + } + + // ===== Start & End labels (TOP of chart, ABOVE rectangles) ===== + private void EnsureStartEndEdgeLabels() + { + if (_startEdgeLabel == null) + { + _startEdgeLabel = new TextBlock { Style = (Style)Application.Current.Resources["EdgeLabelStyle"] }; + HeaderCanvas.Children.Add(_startEdgeLabel); + Canvas.SetZIndex(_startEdgeLabel, 25); + } + + if (_endEdgeLabel == null) + { + _endEdgeLabel = new TextBlock { Style = (Style)Application.Current.Resources["EdgeLabelStyle"] }; + HeaderCanvas.Children.Add(_endEdgeLabel); + Canvas.SetZIndex(_endEdgeLabel, 25); + } + } + + private void UpdateStartEndEdgeLabelsLayout() + { + double w = TimelineCanvas.ActualWidth; + if (w <= 0) + { + return; + } + + _startEdgeLabel.Text = TimeSpanHelper.Convert(StartTime); + _endEdgeLabel.Text = TimeSpanHelper.Convert(EndTime); + + PlaceTopLabelAtTime(_startEdgeLabel, StartTime, w); + PlaceTopLabelAtTime(_endEdgeLabel, EndTime, w); + } + + private void PlaceTopLabelAtTime(TextBlock tb, TimeSpan t, double timelineWidth) + { + double x = Math.Round((t.TotalHours / 24.0) * timelineWidth); + double textW = MeasureTextWidth(tb); + double desiredLeft = x - (textW / 2.0); + + Canvas.SetLeft(tb, Clamp(desiredLeft, 0, timelineWidth - textW)); + Canvas.SetTop(tb, 0); + tb.Visibility = Visibility.Visible; + } + + // ===== Sunrise/Sunset ticks on chart ===== + private void EnsureSunriseSunsetTicks() + { + if (_sunriseTick == null) + { + _sunriseTick = new Line { Style = (Style)Application.Current.Resources["SunRiseMarkerTickStyle"] }; + TimelineCanvas.Children.Add(_sunriseTick); + Canvas.SetZIndex(_sunriseTick, 12); + } + + if (_sunsetTick == null) + { + _sunsetTick = new Line { Style = (Style)Application.Current.Resources["SunSetMarkerTickStyle"] }; + TimelineCanvas.Children.Add(_sunsetTick); + Canvas.SetZIndex(_sunsetTick, 12); + } + } + + private void UpdateSunriseSunsetTicksLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight + 24; + if (w <= 0 || h <= 0) + { + return; + } + + void Place(Line tick, TimeSpan t) + { + double x = Math.Round((t.TotalHours / 24.0) * w); + tick.X1 = x; + tick.X2 = x; + tick.Y1 = 0; + tick.Y2 = h; + } + + if (_sunriseTick != null) + { + if (Sunrise.HasValue) + { + Place(_sunriseTick, Sunrise.Value); + _sunriseTick.Visibility = Visibility.Visible; + } + else + { + _sunriseTick.Visibility = Visibility.Collapsed; + } + } + + if (_sunsetTick != null) + { + if (Sunset.HasValue) + { + Place(_sunsetTick, Sunset.Value); + _sunsetTick.Visibility = Visibility.Visible; + } + else + { + _sunsetTick.Visibility = Visibility.Collapsed; + } + } + } + + // ===== Sunrise/Sunset panels (below chart) ===== + private void EnsureSunPanels() + { + if (_sunrisePanel == null) + { + _sunrisePanel = MakeSunPanel("\uEC8A"); + AnnotationCanvas.Children.Add(_sunrisePanel); + } + + if (_sunsetPanel == null) + { + _sunsetPanel = MakeSunPanel("\uED3A"); + AnnotationCanvas.Children.Add(_sunsetPanel); + } + } + + private StackPanel MakeSunPanel(string iconEmoji) + { + var icon = new FontIcon { Glyph = iconEmoji, Style = (Style)Application.Current.Resources["SunIconStyle"] }; + var sp = new StackPanel { Orientation = Orientation.Vertical, Spacing = 2 }; + sp.Children.Add(icon); + return sp; + } + + private void UpdateSunPanelsLayout() + { + double timelineW = TimelineCanvas.ActualWidth; + double annotationW = AnnotationCanvas.ActualWidth; + if (annotationW <= 0) + { + annotationW = timelineW; + } + + if (timelineW <= 0 || annotationW <= 0) + { + return; + } + + void Place(StackPanel sp, TimeSpan t) + { + double panelW = MeasureElementWidth(sp); + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double left = Clamp(xTimeline - (panelW / 2.0), 0, annotationW - panelW); + Canvas.SetLeft(sp, left); + Canvas.SetTop(sp, 8); + } + + if (_sunrisePanel != null) + { + if (Sunrise.HasValue) + { + ToolTipService.SetToolTip(_sunrisePanel, $"Sunrise: {TimeSpanHelper.Convert(Sunrise.Value)}"); + _sunrisePanel.Visibility = Visibility.Visible; + Place(_sunrisePanel, Sunrise.Value); + } + else + { + ToolTipService.SetToolTip(_sunrisePanel, null); + _sunrisePanel.Visibility = Visibility.Collapsed; + } + } + + if (_sunsetPanel != null) + { + if (Sunset.HasValue) + { + ToolTipService.SetToolTip(_sunsetPanel, $"Sunset: {TimeSpanHelper.Convert(Sunset.Value)}"); + _sunsetPanel.Visibility = Visibility.Visible; + Place(_sunsetPanel, Sunset.Value); + } + else + { + ToolTipService.SetToolTip(_sunsetPanel, null); + _sunsetPanel.Visibility = Visibility.Collapsed; + } + } + } + + // ===== Major labels BELOW chart (00,06,12,18,24) ===== + private void EnsureMajorTickLabels() + { + if (_majorTickBottomLabels.Count > 0) + { + return; + } + + // Includes 24:00 at end + for (int i = 0; i < 5; i++) + { + var tb = new TextBlock { Style = (Style)Application.Current.Resources["MajorTickLabelStyle"] }; + Canvas.SetZIndex(tb, 5); // on annotation canvas + _majorTickBottomLabels.Add(tb); + AnnotationCanvas.Children.Add(tb); + } + } + + private void UpdateMajorTickLabelsLayout() + { + double timelineW = TimelineCanvas.ActualWidth; + double annotationW = AnnotationCanvas.ActualWidth; + if (annotationW <= 0) + { + annotationW = timelineW; + } + + if (timelineW <= 0 || annotationW <= 0) + { + return; + } + + int[] hours = { 0, 6, 12, 18, 24 }; + + // 1) Place labels first + for (int i = 0; i < hours.Length; i++) + { + var tb = _majorTickBottomLabels[i]; + var t = TimeSpan.FromHours(hours[i]); + tb.Text = TimeSpanHelper.Convert(t); + + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double textW = MeasureTextWidth(tb); + double left = xTimeline - (textW / 2.0); + + // Middle ones (06, 12) exact center; edges clamp inside canvas + if (i == 1 || i == 2) + { + Canvas.SetLeft(tb, left); + } + else + { + Canvas.SetLeft(tb, Clamp(left, 0, annotationW - textW)); + } + + Canvas.SetTop(tb, 8); // your existing baseline below chart + tb.Visibility = Visibility.Visible; + } + + // 2) Compute sunrise/sunset occupied horizontal ranges (if present) + (double Left, double Right)? sunriseBounds = null; + (double Left, double Right)? sunsetBounds = null; + + if (Sunrise.HasValue && _sunrisePanel != null) + { + sunriseBounds = GetAnnotationBoundsForTime(Sunrise.Value, timelineW, annotationW, _sunrisePanel); + } + + if (Sunset.HasValue && _sunsetPanel != null) + { + sunsetBounds = GetAnnotationBoundsForTime(Sunset.Value, timelineW, annotationW, _sunsetPanel); + } + + // 3) Hide any label that intersects the sunrise/sunset panel bounds + for (int i = 0; i < hours.Length; i++) + { + var tb = _majorTickBottomLabels[i]; + if (tb.Visibility != Visibility.Visible) + { + continue; + } + + var lbl = GetLabelBounds(tb); + + bool hide = + (sunriseBounds.HasValue && Intersects(lbl, sunriseBounds.Value)) || + (sunsetBounds.HasValue && Intersects(lbl, sunsetBounds.Value)); // include sunset too; remove if you only want sunrise + + tb.Visibility = hide ? Visibility.Collapsed : Visibility.Visible; + } + } + + // ===== Utilities ===== + private static double Clamp(double v, double min, double max) => Math.Max(min, Math.Min(max, v)); + + private static double MeasureElementWidth(FrameworkElement el) + { + el.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return el.DesiredSize.Width; + } + + private static double MeasureTextWidth(TextBlock tb) + { + tb.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return tb.DesiredSize.Width; + } + + private static bool Intersects((double Left, double Right) a, (double Left, double Right) b, double pad = 4) + { + // Horizontal overlap with padding + return !(a.Right + pad <= b.Left || b.Right + pad <= a.Left); + } + + private (double Left, double Right) GetAnnotationBoundsForTime(TimeSpan t, double timelineW, double annotationW, FrameworkElement element) + { + // Compute the *actual* left/right the panel will occupy in AnnotationCanvas + double panelW = MeasureElementWidth(element); + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double left = Clamp(xTimeline - (panelW / 2.0), 0, annotationW - panelW); + return (left, left + panelW); + } + + private (double Left, double Right) GetLabelBounds(TextBlock tb) + { + double w = MeasureTextWidth(tb); + double left = Canvas.GetLeft(tb); + return (left, left + w); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml new file mode 100644 index 0000000000..82acf66ef5 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml new file mode 100644 index 0000000000..b1f4c3f6fc --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs new file mode 100644 index 0000000000..a3c8486b8f --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.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 + { + 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 IFileSystem _fileSystem; + private readonly IFileSystemWatcher _fileSystemWatcher; + private readonly DispatcherQueue _dispatcherQueue; + + private LightSwitchViewModel ViewModel { get; set; } + + public LightSwitchPage() + { + _settingsUtils = new SettingsUtils(); + _sendConfigMsg = ShellPage.SendDefaultIPCMessage; + + _generalSettingsRepository = SettingsRepository.GetInstance(_settingsUtils); + _moduleSettingsRepository = SettingsRepository.GetInstance(_settingsUtils); + + // Get settings from JSON (or defaults if JSON missing) + var darkSettings = _moduleSettingsRepository.SettingsConfig; + + // Pass them into the ViewModel + ViewModel = new LightSwitchViewModel(darkSettings, ShellPage.SendDefaultIPCMessage); + ViewModel.PropertyChanged += ViewModel_PropertyChanged; + + LoadSettings(_generalSettingsRepository, _moduleSettingsRepository); + + DataContext = ViewModel; + + var settingsPath = _settingsUtils.GetSettingsFilePath(_appName); + + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _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.InitializeComponent(); + this.Loaded += LightSwitchPage_Loaded; + } + + private void LightSwitchPage_Loaded(object sender, RoutedEventArgs e) + { + if (ViewModel.SearchLocations.Count == 0) + { + foreach (var city in SearchLocationLoader.GetAll()) + { + ViewModel.SearchLocations.Add(city); + } + } + + ViewModel.InitializeScheduleMode(); + } + + private async Task GetGeoLocation() + { + SyncButton.IsEnabled = false; + SyncLoader.IsActive = true; + SyncLoader.Visibility = Visibility.Visible; + + try + { + // Request access + var accessStatus = await Geolocator.RequestAccessAsync(); + if (accessStatus != GeolocationAccessStatus.Allowed) + { + // User denied location or it's not available + return; + } + + var geolocator = new Geolocator { DesiredAccuracy = PositionAccuracy.Default }; + + Geoposition pos = await geolocator.GetGeopositionAsync(); + + double latitude = Math.Round(pos.Coordinate.Point.Position.Latitude); + double longitude = Math.Round(pos.Coordinate.Point.Position.Longitude); + + SunTimes result = SunCalc.CalculateSunriseSunset( + latitude, + longitude, + DateTime.Now.Year, + DateTime.Now.Month, + DateTime.Now.Day); + + 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); + + // 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}°"; + + // 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; + } + catch (Exception ex) + { + SyncButton.IsEnabled = true; + SyncLoader.IsActive = false; + System.Diagnostics.Debug.WriteLine("Location error: " + ex.Message); + } + } + + private void LocationDialog_PrimaryButtonClick(object sender, ContentDialogButtonClickEventArgs args) + { + if (ViewModel.ScheduleMode == "SunriseToSunsetUser") + { + ViewModel.SyncButtonInformation = ViewModel.SelectedCity.City; + } + else if (ViewModel.ScheduleMode == "SunriseToSunsetGeo") + { + ViewModel.SyncButtonInformation = $"{ViewModel.Latitude}°, {ViewModel.Longitude}°"; + } + + SunriseModeChartState(); + } + + private void ViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == "IsEnabled") + { + if (ViewModel.IsEnabled != _generalSettingsRepository.SettingsConfig.Enabled.LightSwitch) + { + _generalSettingsRepository.SettingsConfig.Enabled.LightSwitch = ViewModel.IsEnabled; + + var generalSettingsMessage = new OutGoingGeneralSettings(_generalSettingsRepository.SettingsConfig).ToString(); + Logger.LogInfo($"Saved general settings from Light Switch page."); + + _sendConfigMsg?.Invoke(generalSettingsMessage); + } + } + else + { + if (ViewModel.ModuleSettings != null) + { + SndLightSwitchSettings currentSettings = new(_moduleSettingsRepository.SettingsConfig); + SndModuleSettings csIpcMessage = new(currentSettings); + + SndLightSwitchSettings outSettings = new(ViewModel.ModuleSettings); + SndModuleSettings outIpcMessage = new(outSettings); + + string csMessage = csIpcMessage.ToJsonString(); + string outMessage = outIpcMessage.ToJsonString(); + + if (!csMessage.Equals(outMessage, StringComparison.Ordinal)) + { + Logger.LogInfo($"Saved Light Switch settings from Light Switch page."); + + _sendConfigMsg?.Invoke(outMessage); + } + } + } + } + + private void LoadSettings(ISettingsRepository generalSettingsRepository, ISettingsRepository moduleSettingsRepository) + { + if (generalSettingsRepository != null) + { + if (moduleSettingsRepository != null) + { + UpdateViewModelSettings(moduleSettingsRepository.SettingsConfig, generalSettingsRepository.SettingsConfig); + } + else + { + throw new ArgumentNullException(nameof(moduleSettingsRepository)); + } + } + else + { + throw new ArgumentNullException(nameof(generalSettingsRepository)); + } + } + + private void UpdateViewModelSettings(LightSwitchSettings lightSwitchSettings, GeneralSettings generalSettings) + { + if (lightSwitchSettings != null) + { + if (generalSettings != null) + { + ViewModel.IsEnabled = generalSettings.Enabled.LightSwitch; + ViewModel.ModuleSettings = (LightSwitchSettings)lightSwitchSettings.Clone(); + + UpdateEnabledState(generalSettings.Enabled.LightSwitch); + } + else + { + throw new ArgumentNullException(nameof(generalSettings)); + } + } + else + { + throw new ArgumentNullException(nameof(lightSwitchSettings)); + } + } + + private void Settings_Changed(object sender, FileSystemEventArgs e) + { + _dispatcherQueue.TryEnqueue(() => + { + _moduleSettingsRepository.ReloadSettings(); + LoadSettings(_generalSettingsRepository, _moduleSettingsRepository); + }); + } + + private void UpdateEnabledState(bool recommendedState) + { + var enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredLightSwitchEnabledValue(); + + if (enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + ViewModel.IsEnabledGpoConfigured = true; + ViewModel.EnabledGPOConfiguration = enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + } + else + { + ViewModel.IsEnabled = recommendedState; + } + } + + private async void SyncLocationButton_Click(object sender, RoutedEventArgs e) + { + LocationDialog.IsPrimaryButtonEnabled = false; + LocationResultPanel.Visibility = Visibility.Collapsed; + await LocationDialog.ShowAsync(); + } + + private void CityAutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput && !string.IsNullOrWhiteSpace(sender.Text)) + { + string query = sender.Text.ToLower(CultureInfo.CurrentCulture); + + // Filter your cities (assuming ViewModel.Cities is a List) + var filtered = ViewModel.SearchLocations + .Where(c => + (c.City?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false) || + (c.Country?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false)) + .ToList(); + + sender.ItemsSource = filtered; + } + } + + private void CityAutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + if (args.SelectedItem is SearchLocation location) + { + ViewModel.SelectedCity = location; + + // CityAutoSuggestBox.Text = $"{location.City}, {location.Country}"; + LocationDialog.IsPrimaryButtonEnabled = true; + LocationResultPanel.Visibility = Visibility.Visible; + } + } + + private void ModeSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + SunriseModeChartState(); + } + + private void SunriseModeChartState() + { + if (ViewModel.Latitude == "0.0" && ViewModel.Longitude == "0.0" && ViewModel.ScheduleMode == "SunsetToSunrise") + { + TimelineCard.Visibility = Visibility.Collapsed; + LocationWarningBar.Visibility = Visibility.Visible; + } + else + { + TimelineCard.Visibility = Visibility.Visible; + LocationWarningBar.Visibility = Visibility.Collapsed; + } + } + + private async void LocationDialog_Opened(ContentDialog sender, ContentDialogOpenedEventArgs args) + { + await GetGeoLocation(); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml index 09f568f4a6..2869e2042f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml @@ -30,7 +30,6 @@ Command="{x:Bind ViewModel.LaunchEventHandler}" HeaderIcon="{ui:FontIcon Glyph=}" IsClickEnabled="True" /> - + You'll be asked to confirm before files are moved to the Recycle Bin - + Activation method - + Use a shortcut or press the Spacebar when a file is selected - Spacebar is a physical keyboard key + Spacebar is a physical keyboard key - + Custom shortcut - + Spacebar - + Disable rounded corners when a window is snapped @@ -3261,6 +3261,15 @@ Activate by holding the key for the character you want to add an accent to, then Pin a window + + Theme toggle shortcut + + + Switch between light and dark mode + + + Toggle theme + Pick a color @@ -5224,6 +5233,117 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Stores diagnostic data locally in .xml format; folder may include .etl files as well. May use up 1 GB or more of disk space. + + Light Switch + + + Enable Light Switch + + + Easily switch between light and dark mode - on a schedule, automatically, or with a shortcut. + + + Light Switch + + + Learn more about Light Switch + + + Behavior + + + Enable Light Switch + + + Shortcuts + + + Schedule + + + Mode + + + Determine when dark mode should be turned on + + + Manual + + + Sunset to sunrise + + + Turn on dark mode + + + Turn off dark mode + + + Location + + + Used to automatically calculate accurate sunrise and sunset times + + + Offset (in minutes) + + + Adjust the trigger time by starting earlier or later + + + Location required + + + Sync your location so Light Switch can calculate the correct sunrise- and sunset times + + + Apply dark mode to + + + Pick which parts of your PC should follow Light Switch + + + System + + + Taskbar, Start, and other system UI + + + Apps + + + Supported applications + + + Select a location + + + Select + + + Cancel + + + Get current location + + + To calculate the sunrise and sunset, Light Switch needs a location. + + + Sunrise + + + Sunset + + + Location + + + Sunrise + + + Sunset + Close PowerToys Don't loc "PowerToys" @@ -5321,6 +5441,22 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Utilities + + Light Switch + Product name. Do not localize this string + + + Light Switch automatically manages your Windows light and dark mode based on schedules, sunrise/sunset times, or manual control. Keep your system theme synchronized with your preferences and daily rhythm. + Light Switch is a product name, do not localize + + + Open **PowerToys Settings** and enable Light Switch to set up automatic theme switching + Light Switch is a product name, do not localize + + + Use the **keyboard shortcut** to instantly toggle between light and dark modes, or set up **sunrise/sunset automation** for natural theme transitions. + Light Switch is a product name, do not localize + Dismiss diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs index 82d3aa358d..344eaa183f 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs @@ -22,6 +22,7 @@ using Microsoft.PowerToys.Settings.UI.Services; using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.ViewModels { @@ -226,6 +227,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels ModuleType.FancyZones => GetModuleItemsFancyZones(), ModuleType.FindMyMouse => GetModuleItemsFindMyMouse(), ModuleType.Hosts => GetModuleItemsHosts(), + ModuleType.LightSwitch => GetModuleItemsLightSwitch(), ModuleType.MouseHighlighter => GetModuleItemsMouseHighlighter(), ModuleType.MouseJump => GetModuleItemsMouseJump(), ModuleType.MousePointerCrosshairs => GetModuleItemsMousePointerCrosshairs(), @@ -274,6 +276,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels return new ObservableCollection(list); } + private ObservableCollection GetModuleItemsLightSwitch() + { + ISettingsRepository moduleSettingsRepository = SettingsRepository.GetInstance(new SettingsUtils()); + var settings = moduleSettingsRepository.SettingsConfig; + var list = new List + { + new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("LightSwitch_ForceDarkMode"), Shortcut = settings.Properties.ToggleThemeHotkey.Value.GetKeysList() }, + }; + return new ObservableCollection(list); + } + private ObservableCollection GetModuleItemsCropAndLock() { ISettingsRepository moduleSettingsRepository = SettingsRepository.GetInstance(new SettingsUtils()); diff --git a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs new file mode 100644 index 0000000000..e5262f93d7 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft Corporation +// The 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.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Windows.Input; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Newtonsoft.Json.Linq; +using Settings.UI.Library; +using Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public partial class LightSwitchViewModel : Observable + { + private Func SendConfigMSG { get; } + + public ObservableCollection SearchLocations { get; } = new(); + + public LightSwitchViewModel(LightSwitchSettings initialSettings = null, Func ipcMSGCallBackFunc = null) + { + _moduleSettings = initialSettings ?? new LightSwitchSettings(); + SendConfigMSG = ipcMSGCallBackFunc; + + ForceLightCommand = new RelayCommand(ForceLightNow); + ForceDarkCommand = new RelayCommand(ForceDarkNow); + + AvailableScheduleModes = new ObservableCollection + { + "FixedHours", + "SunsetToSunrise", + }; + + _toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value; + } + + private void ForceLightNow() + { + Logger.LogInfo("Sending custom action: forceLight"); + SendCustomAction("forceLight"); + } + + private void ForceDarkNow() + { + Logger.LogInfo("Sending custom action: forceDark"); + SendCustomAction("forceDark"); + } + + private void SendCustomAction(string actionName) + { + SendConfigMSG("{\"action\":{\"LightSwitch\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}"); + } + + public LightSwitchSettings ModuleSettings + { + get => _moduleSettings; + set + { + if (_moduleSettings != value) + { + _moduleSettings = value; + + OnPropertyChanged(nameof(ModuleSettings)); + RefreshModuleSettings(); + RefreshEnabledState(); + } + } + } + + public bool IsEnabled + { + get + { + if (_enabledStateIsGPOConfigured) + { + return _enabledGPOConfiguration; + } + else + { + return _isEnabled; + } + } + + set + { + if (_isEnabled != value) + { + if (_enabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + _isEnabled = value; + + RefreshEnabledState(); + + NotifyPropertyChanged(); + } + } + } + + public bool IsEnabledGpoConfigured + { + get => _enabledStateIsGPOConfigured; + set + { + if (_enabledStateIsGPOConfigured != value) + { + _enabledStateIsGPOConfigured = value; + NotifyPropertyChanged(); + } + } + } + + public bool EnabledGPOConfiguration + { + get => _enabledGPOConfiguration; + set + { + if (_enabledGPOConfiguration != value) + { + _enabledGPOConfiguration = value; + NotifyPropertyChanged(); + } + } + } + + public string ScheduleMode + { + get => ModuleSettings.Properties.ScheduleMode.Value; + set + { + var oldMode = ModuleSettings.Properties.ScheduleMode.Value; + if (ModuleSettings.Properties.ScheduleMode.Value != value) + { + ModuleSettings.Properties.ScheduleMode.Value = value; + OnPropertyChanged(nameof(ScheduleMode)); + } + + if (ModuleSettings.Properties.ScheduleMode.Value == "FixedHours" && oldMode != "FixedHours") + { + LightTime = 360; + DarkTime = 1080; + SunsetTimeSpan = null; + SunriseTimeSpan = null; + + OnPropertyChanged(nameof(LightTimePickerValue)); + OnPropertyChanged(nameof(DarkTimePickerValue)); + } + + if (ModuleSettings.Properties.ScheduleMode.Value == "SunsetToSunrise") + { + if (ModuleSettings.Properties.Latitude != "0.0" && ModuleSettings.Properties.Longitude != "0.0") + { + double lat = double.Parse(ModuleSettings.Properties.Latitude.Value, CultureInfo.InvariantCulture); + double lon = double.Parse(ModuleSettings.Properties.Longitude.Value, CultureInfo.InvariantCulture); + UpdateSunTimes(lat, lon); + } + } + } + } + + public ObservableCollection AvailableScheduleModes { get; } + + public bool ChangeSystem + { + get => ModuleSettings.Properties.ChangeSystem.Value; + set + { + if (ModuleSettings.Properties.ChangeSystem.Value != value) + { + ModuleSettings.Properties.ChangeSystem.Value = value; + NotifyPropertyChanged(); + } + } + } + + public bool ChangeApps + { + get => ModuleSettings.Properties.ChangeApps.Value; + set + { + if (ModuleSettings.Properties.ChangeApps.Value != value) + { + ModuleSettings.Properties.ChangeApps.Value = value; + NotifyPropertyChanged(); + } + } + } + + public int LightTime + { + get => ModuleSettings.Properties.LightTime.Value; + set + { + if (ModuleSettings.Properties.LightTime.Value != value) + { + ModuleSettings.Properties.LightTime.Value = value; + NotifyPropertyChanged(); + + OnPropertyChanged(nameof(LightTimeTimeSpan)); + + if (ScheduleMode == "SunsetToSunrise") + { + SunriseTimeSpan = TimeSpan.FromMinutes(value); + } + } + } + } + + public int DarkTime + { + get => ModuleSettings.Properties.DarkTime.Value; + set + { + if (ModuleSettings.Properties.DarkTime.Value != value) + { + ModuleSettings.Properties.DarkTime.Value = value; + NotifyPropertyChanged(); + + OnPropertyChanged(nameof(DarkTimeTimeSpan)); + + if (ScheduleMode == "SunsetToSunrise") + { + SunsetTimeSpan = TimeSpan.FromMinutes(value); + } + } + } + } + + public int SunriseOffset + { + get => ModuleSettings.Properties.SunriseOffset.Value; + set + { + if (ModuleSettings.Properties.SunriseOffset.Value != value) + { + ModuleSettings.Properties.SunriseOffset.Value = value; + OnPropertyChanged(nameof(LightTimeTimeSpan)); + } + } + } + + public int SunsetOffset + { + get => ModuleSettings.Properties.SunsetOffset.Value; + set + { + if (ModuleSettings.Properties.SunsetOffset.Value != value) + { + ModuleSettings.Properties.SunsetOffset.Value = value; + OnPropertyChanged(nameof(DarkTimeTimeSpan)); + } + } + } + + // === Computed projections (OneWay bindings only) === + public TimeSpan LightTimeTimeSpan + { + get + { + if (ScheduleMode == "SunsetToSunrise") + { + return TimeSpan.FromMinutes(LightTime + SunriseOffset); + } + else + { + return TimeSpan.FromMinutes(LightTime); + } + } + } + + public TimeSpan DarkTimeTimeSpan + { + get + { + if (ScheduleMode == "SunsetToSunrise") + { + return TimeSpan.FromMinutes(DarkTime + SunsetOffset); + } + else + { + return TimeSpan.FromMinutes(DarkTime); + } + } + } + + // === Values to pass to timeline === + public TimeSpan? SunriseTimeSpan + { + get => _sunriseTimeSpan; + set + { + if (_sunriseTimeSpan != value) + { + _sunriseTimeSpan = value; + NotifyPropertyChanged(); + } + } + } + + public TimeSpan? SunsetTimeSpan + { + get => _sunsetTimeSpan; + set + { + if (_sunsetTimeSpan != value) + { + _sunsetTimeSpan = value; + NotifyPropertyChanged(); + } + } + } + + // === Picker values (TwoWay binding targets for TimePickers) === + public TimeSpan LightTimePickerValue + { + get => TimeSpan.FromMinutes(LightTime); + set => LightTime = (int)value.TotalMinutes; + } + + public TimeSpan DarkTimePickerValue + { + get => TimeSpan.FromMinutes(DarkTime); + set => DarkTime = (int)value.TotalMinutes; + } + + public string Latitude + { + get => ModuleSettings.Properties.Latitude.Value; + set + { + if (ModuleSettings.Properties.Latitude.Value != value) + { + ModuleSettings.Properties.Latitude.Value = value; + NotifyPropertyChanged(); + } + } + } + + public string Longitude + { + get => ModuleSettings.Properties.Longitude.Value; + set + { + if (ModuleSettings.Properties.Longitude.Value != value) + { + ModuleSettings.Properties.Longitude.Value = value; + NotifyPropertyChanged(); + } + } + } + + private SearchLocation _selectedSearchLocation; + + public SearchLocation SelectedCity + { + get => _selectedSearchLocation; + set + { + if (_selectedSearchLocation != value) + { + _selectedSearchLocation = value; + NotifyPropertyChanged(); + + UpdateSunTimes(_selectedSearchLocation.Latitude, _selectedSearchLocation.Longitude, _selectedSearchLocation.City); + } + } + } + + private string _syncButtonInformation = "Please sync your location"; + + public string SyncButtonInformation + { + get => _syncButtonInformation; + set + { + if (_syncButtonInformation != value) + { + _syncButtonInformation = value; + NotifyPropertyChanged(); + } + } + } + + public HotkeySettings ToggleThemeActivationShortcut + { + get => _toggleThemeHotkey; + + set + { + if (value != _toggleThemeHotkey) + { + if (value == null) + { + _toggleThemeHotkey = LightSwitchProperties.DefaultToggleThemeHotkey; + } + else + { + _toggleThemeHotkey = value; + } + + _moduleSettings.Properties.ToggleThemeHotkey.Value = _toggleThemeHotkey; + NotifyPropertyChanged(); + + SendConfigMSG( + string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + LightSwitchSettings.ModuleName, + JsonSerializer.Serialize(_moduleSettings, (System.Text.Json.Serialization.Metadata.JsonTypeInfo)SourceGenerationContextContext.Default.LightSwitchSettings))); + } + } + } + + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + { + Logger.LogInfo($"Changed the property {propertyName}"); + OnPropertyChanged(propertyName); + } + + public void RefreshEnabledState() + { + OnPropertyChanged(nameof(IsEnabled)); + } + + public void RefreshModuleSettings() + { + OnPropertyChanged(nameof(ChangeSystem)); + OnPropertyChanged(nameof(ChangeApps)); + OnPropertyChanged(nameof(LightTime)); + OnPropertyChanged(nameof(DarkTime)); + OnPropertyChanged(nameof(SunriseOffset)); + OnPropertyChanged(nameof(SunsetOffset)); + OnPropertyChanged(nameof(Latitude)); + OnPropertyChanged(nameof(Longitude)); + OnPropertyChanged(nameof(ScheduleMode)); + } + + private void UpdateSunTimes(double latitude, double longitude, string city = "n/a") + { + SunTimes result = SunCalc.CalculateSunriseSunset( + latitude, + longitude, + DateTime.Now.Year, + DateTime.Now.Month, + DateTime.Now.Day); + + LightTime = (result.SunriseHour * 60) + result.SunriseMinute; + DarkTime = (result.SunsetHour * 60) + result.SunsetMinute; + Latitude = latitude.ToString(CultureInfo.InvariantCulture); + Longitude = longitude.ToString(CultureInfo.InvariantCulture); + + if (city != "n/a") + { + SyncButtonInformation = city; + } + } + + public void InitializeScheduleMode() + { + if (ScheduleMode == "SunsetToSunrise" && + double.TryParse(Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out double savedLat) && + double.TryParse(Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out double savedLng)) + { + var match = SearchLocations.FirstOrDefault(c => + Math.Abs(c.Latitude - savedLat) < 0.0001 && + Math.Abs(c.Longitude - savedLng) < 0.0001); + + if (match != null) + { + SelectedCity = match; + } + + SyncButtonInformation = SelectedCity != null + ? SelectedCity.City + : $"{Latitude},{Longitude}"; + + double lat = double.Parse(ModuleSettings.Properties.Latitude.Value, CultureInfo.InvariantCulture); + double lon = double.Parse(ModuleSettings.Properties.Longitude.Value, CultureInfo.InvariantCulture); + UpdateSunTimes(lat, lon); + + SunriseTimeSpan = TimeSpan.FromMinutes(LightTime); + SunsetTimeSpan = TimeSpan.FromMinutes(DarkTime); + } + } + + private bool _enabledStateIsGPOConfigured; + private bool _enabledGPOConfiguration; + private LightSwitchSettings _moduleSettings; + private bool _isEnabled; + private HotkeySettings _toggleThemeHotkey; + private TimeSpan? _sunriseTimeSpan; + private TimeSpan? _sunsetTimeSpan; + + public ICommand ForceLightCommand { get; } + + public ICommand ForceDarkCommand { get; } + } +} diff --git a/tools/BugReportTool/BugReportTool/ProcessesList.cpp b/tools/BugReportTool/BugReportTool/ProcessesList.cpp index 1aa1d1d708..eb4a976c9b 100644 --- a/tools/BugReportTool/BugReportTool/ProcessesList.cpp +++ b/tools/BugReportTool/BugReportTool/ProcessesList.cpp @@ -11,6 +11,7 @@ std::vector processes = L"PowerToys.FancyZonesEditor.exe", L"PowerToys.FancyZones.exe", L"PowerToys.FileLocksmithUI.exe", + L"PowerToys.LightSwitch.exe", L"PowerToys.KeyboardManagerEngine.exe", L"PowerToys.KeyboardManagerEditor.exe", L"PowerToys.PowerAccent.exe", diff --git a/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp b/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp index 3c042ee06d..c6b3bbe372 100644 --- a/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp +++ b/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp @@ -50,6 +50,7 @@ void ReportGPOValues(const std::filesystem::path &tmpDir) report << "getConfiguredCropAndLockEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredCropAndLockEnabledValue()) << std::endl; report << "getConfiguredFancyZonesEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredFancyZonesEnabledValue()) << std::endl; report << "getConfiguredFileLocksmithEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredFileLocksmithEnabledValue()) << std::endl; + report << "getConfiguredLightSwitchEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredLightSwitchEnabledValue()) << std::endl; report << "getConfiguredSvgPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredSvgPreviewEnabledValue()) << std::endl; report << "getConfiguredMarkdownPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMarkdownPreviewEnabledValue()) << std::endl; report << "getConfiguredMonacoPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMonacoPreviewEnabledValue()) << std::endl;