Lumos.psm1
|
Function Invoke-Lumos { <# .SYNOPSIS Sets the Windows or Mac Theme to light or dark mode dependent on time of day. .DESCRIPTION Use this cmdlet to change the theme on Windows 10 or MacOS Mojave to the light of dark themes, either as specified by parameters or (for Windows only), automatically based on the local time of day and whether it is before or after sunrise/sunset. .PARAMETER Dark Switch to the Dark OS theme. .PARAMETER Light Switch to the Light OS theme. .PARAMETER ExcludeSystem Exclude changing the System theme when switching to Dark/Light (Windows only). .PARAMETER RestartExplorer Restart Explorer to apply the System theme change to the taskbar (Windows only), instead of the default of broadcasting a WM_SETTINGCHANGE message. Use this if the taskbar still doesn't update without it on your system. .PARAMETER IncludeOfficeProPlus Include changing the theme of Microsoft Office to Dark/Light (Windows only). .PARAMETER ExcludeApps Exclude changing the Applications (where supported) theme when switching to Dark/Light (Windows only). .PARAMETER DarkWallpaper Specify a path to use to modify the Desktop Wallpaper to when switching to the Dark theme. .PARAMETER LightWallpaper Specify a path to use to modify the Desktop Wallpaper to when switching to the Light theme. .EXAMPLE Invoke-Lumos -Dark -DarkWallpaper ./dark-wallpaper.png Switches the OS theme to the Dark theme and specified Wallpaper. .EXAMPLE Invoke-Lumos -Light -LightWallpaper ./light-wallpaper.png Swithches the OS theme to the Light theme and specified Wallpaper. .EXAMPLE Invoke-Lumos -Dark -ExcludeApps Switches the OS theme to Dark, but (on Windows only) does not change the theme of apps that support Dark/Light theme. .EXAMPLE Invoke-Lumos -Dark -RestartExplorer Switches the OS theme to Dark and restarts Explorer (Windows only) to apply the change to the taskbar, instead of the default of broadcasting a WM_SETTINGCHANGE message. .Example Invoke-Lumos On Windows: Switches to either Dark or Light theme dependent on your current location/time of day. On MacOS: Switches current theme from either Light to Dark or Dark to Light. #> [cmdletbinding(DefaultParameterSetName = 'Dark')] Param( [Parameter(ParameterSetName = 'Dark')] [switch] $Dark, [Parameter(ParameterSetName = 'Light')] [switch] $Light, [switch] $ExcludeSystem, [switch] $RestartExplorer, [switch] $IncludeOfficeProPlus, [switch] $ExcludeApps, [string] $DarkWallpaper, [string] $LightWallpaper ) if ($Dark) { $Lumos = 0 } elseif ($Light) { $Lumos = 1 } elseif ($IsMacOS) { ### MacOS ### # Leaving Lumos as undefined on MacOS will make it just alternate to whatever mode it currently is not $Lumos = 'Undefined' } else { ### Windows ### $CurrentTime = Get-Date $UserLocation = Get-UserLocation if ($UserLocation) { $DayLight = Get-LocalDaylight -Latitude $UserLocation.Latitude -Longitude $UserLocation.Longitude } else { Throw 'Could not get sunrise/sunset data for the current user.' } if ($CurrentTime -ge $DayLight.Sunrise -and $CurrentTime -lt $DayLight.Sunset) { $Lumos = 1 } else { $Lumos = 0 } } Switch ($Lumos) { 0 { $Status = 'Dark' if ($DarkWallpaper) { $Wallpaper = $DarkWallpaper } } 1 { $Status = 'Light' if ($LightWallpaper) { $Wallpaper = $LightWallpaper } } default { $Status = 'Undefined' } } if ($IsMacOS) { ### MacOS ### $MacCommand = if ($Lumos -eq 0) { 'tell application \"System Events\" to tell appearance preferences to set dark mode to true' } elseif ($Lumos -eq 1) { 'tell application \"System Events\" to tell appearance preferences to set dark mode to false' } else { 'tell application \"System Events\" to tell appearance preferences to set dark mode to not dark mode' } Invoke-AppleScript -Command $MacCommand if ($ExcludeSystem) { Write-Error '-ExcludeSystem is not currently supported on MacOS.' } if ($ExcludeApps) { Write-Error '-ExcludeApps is not currently supported on MacOS.' } if ($IncludeOfficeProPlus) { Write-Error '-OfficeProPlus is not currently supported on MacOS.' } if ($Wallpaper) { $MacCommand = "tell application \`"System Events\`" to tell current desktop to set picture to \`"$Wallpaper\`"" Invoke-AppleScript -Command $MacCommand } } elseif ($IsLinux) { ### Linux ### Throw 'Linux is not currently supported by this module.' } else { ### Windows ### $ThemeRegKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' $OfficeThemeRegKey = 'HKCU:\Software\Microsoft\Office\16.0\Common' if (-not $ExcludeSystem) { $CurrentSystemTheme = (Get-ItemProperty -Path $ThemeRegKey -Name 'SystemUsesLightTheme' -ErrorAction SilentlyContinue).SystemUsesLightTheme if ($CurrentSystemTheme -ne $Lumos) { Write-Verbose "Setting System to $Status Theme.." Set-ItemProperty -Path $ThemeRegKey -Name 'SystemUsesLightTheme' -Value $Lumos # The taskbar (and Start, Action Center) follow SystemUsesLightTheme, not AppsUseLightTheme, and # need to be told the setting changed before they'll repaint. Only doing this when the theme # actually changed avoids the disruption of restarting Explorer (or even the lighter-weight # broadcast below) on every run of a frequently scheduled task. if ($RestartExplorer) { Write-Verbose 'Restarting Explorer to apply the theme change to the taskbar..' Stop-Process -ProcessName explorer } else { Write-Verbose 'Broadcasting a WM_SETTINGCHANGE message to apply the theme change to the taskbar..' Send-SettingChangeMessage } } } if (-not $ExcludeApps) { $CurrentAppsTheme = (Get-ItemProperty -Path $ThemeRegKey -Name 'AppsUseLightTheme' -ErrorAction SilentlyContinue).AppsUseLightTheme if ($CurrentAppsTheme -ne $Lumos) { Write-Verbose "Setting Apps to $Status Theme.." Set-ItemProperty -Path $ThemeRegKey -Name 'AppsUseLightTheme' -Value $Lumos } } if ($IncludeOfficeProPlus) { $proPlusThemeValue = if ($Lumos -eq 0) { 4 } else { if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\O365ProPlusRetail*") { 5 } else { 0 } } Write-Verbose "Setting OfficeProPlus to $Status with value: $proPlusThemeValue .." Set-ItemProperty -Path $OfficeThemeRegKey -Name 'UI Theme' -Value $proPlusThemeValue -Type DWORD $IdentitiesRegKey = $OfficeThemeRegKey + "\Roaming\Identities\" if (Test-Path $IdentitiesRegKey) { Get-ChildItem -Path $IdentitiesRegKey | ForEach-Object { $identityPath = ($_.Name.Replace('HKEY_CURRENT_USER', 'HKCU:') + "\Settings\1186\{00000000-0000-0000-0000-000000000000}"); if (Get-ItemProperty -Path $identityPath -Name 'Data' -ErrorAction Ignore) { Write-Verbose "Active identity path for ProPlus installation: $identityPath" Set-ItemProperty -Path $identityPath -Name 'Data' -Value ([byte[]]($proPlusThemeValue, 0, 0, 0)) -Type Binary } Break } } } if ($Wallpaper) { Set-Wallpaper $Wallpaper } } } Function Register-LumosScheduledTask { <# .SYNOPSIS Registers Scheduled Tasks to run Lumos automatically on Windows. .DESCRIPTION Use this cmdlet to register a "Lumos" scheduled task on Windows that runs Invoke-Lumos (with your specified parameters) twice daily, at the current sunrise and sunset for your location. Since sunrise/sunset drift through the year, this also registers a second "Lumos-Maintenance" task that runs once weekly, at solar noon (the point furthest from both sunrise and sunset) on whichever day of the week this cmdlet was run, and whose only job is to recompute the current sunrise/sunset and update the "Lumos" task's trigger times to match via Update-LumosScheduledTask - weekly is frequent enough to keep the triggers close to sunrise/sunset without needing daily API calls to determine location and daylight times. Keeping this in a separate task - rather than as a second action of the "Lumos" task itself, as an earlier version of this module did - matters: Windows Task Scheduler won't let a task modify its own definition while it's the active running instance, which is what made that earlier approach throw "Access is denied" and lose its triggers entirely. Running the update from a genuinely different task, at a time unlikely to overlap with "Lumos" actually running, avoids that. If you'd rather not rely on the automatic sunrise/sunset lookup (or don't want Lumos calling out to it at all), specify -Sunrise and -Sunset yourself to use fixed daily trigger times instead, or use -FromNightLight to reuse whichever schedule Windows' own Night Light feature is currently configured with. Since neither of those need to be kept current with the season the way an automatic lookup does, the "Lumos-Maintenance" task isn't registered in either case - re-run this cmdlet if the Night Light schedule you're reusing later changes. If a "Lumos-Maintenance" task was already registered from a previous run (e.g. you're switching from the automatic lookup to a fixed schedule), it's removed, since it would otherwise keep overwriting your fixed times weekly. Both tasks run as the current user at standard (non-elevated) privilege - Lumos only ever changes current-user settings, so no administrator rights are required. Neither task has an "at logon" trigger, since some endpoint security software blocks non-admin users from registering one (likely because it's a common persistence technique). Returns the registered scheduled task(s), displayed with their schedule source and a summary of the times that were registered (e.g. to verify -FromNightLight or the automatic sunrise/sunset lookup picked up what you expected) - the full underlying task, including its Triggers, is still there to inspect if you need more detail. .PARAMETER Sunrise Specify a fixed daily time to switch to the Light theme, instead of automatically looking up the current sunrise for your location. Must be specified together with -Sunset. Since this time won't need to stay current with the season, the "Lumos-Maintenance" task is not registered. .PARAMETER Sunset Specify a fixed daily time to switch to the Dark theme, instead of automatically looking up the current sunset for your location. Must be specified together with -Sunrise. Since this time won't need to stay current with the season, the "Lumos-Maintenance" task is not registered. .PARAMETER FromNightLight Use whichever schedule Windows' own Night Light feature (Settings > System > Display > Night light) is currently configured with, instead of automatically looking up sunrise/sunset for your location. Cannot be combined with -Sunrise/-Sunset. Since this is read once at registration time (not kept in sync with Night Light afterwards), the "Lumos-Maintenance" task is not registered - re-run this cmdlet if you later change your Night Light schedule. .PARAMETER ExcludeSystem Exclude changing the System theme when switching to Dark/Light (Windows only) when the task runs. .PARAMETER RestartExplorer Restart Explorer to apply the System theme change to the taskbar when the task runs, instead of the default of broadcasting a WM_SETTINGCHANGE message. .PARAMETER IncludeOfficeProPlus Include changing the theme of Microsoft Office to Dark/Light (Windows only) when the task runs. .PARAMETER ExcludeApps Exclude changing the Applications (where supported) theme when switching to Dark/Light (Windows only) when the task runs. .PARAMETER DarkWallpaper Specify a path to use to modify the Desktop Wallpaper to when the task runs and switches to the Dark theme. .PARAMETER LightWallpaper Specify a path to use to modify the Desktop Wallpaper to when the task runs and switches to the Light theme. .EXAMPLE Register-LumosScheduledTask -ExcludeApps -DarkWallpaper C:\Temp\dark.png -LightWallpaper C:\Temp\light.png Creates scheduled tasks that switch just the OS theme to dark or light at the current local sunrise and sunset, along with the specified light or dark wallpaper, keeping the trigger times themselves up to date with sunrise/sunset as they change through the year. .EXAMPLE Register-LumosScheduledTask -Sunrise '07:00' -Sunset '19:00' Creates a "Lumos" scheduled task that switches to the Light theme at 07:00 and the Dark theme at 19:00 every day, without looking up your location or registering a "Lumos-Maintenance" task. .EXAMPLE Register-LumosScheduledTask -FromNightLight Creates a "Lumos" scheduled task using whichever schedule Windows' own Night Light feature is currently configured with, without looking up your location or registering a "Lumos-Maintenance" task. #> [cmdletbinding()] [OutputType([Microsoft.Management.Infrastructure.CimInstance])] Param( [datetime] $Sunrise, [datetime] $Sunset, [switch] $FromNightLight, [switch] $ExcludeSystem, [switch] $RestartExplorer, [switch] $ExcludeApps, [switch] $IncludeOfficeProPlus, [string] $DarkWallpaper, [string] $LightWallpaper ) if (-not ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows)) { Write-Warning 'Register-LumosScheduledTask is only supported on Windows.' return } if (($Sunrise -and -not $Sunset) -or ($Sunset -and -not $Sunrise)) { throw '-Sunrise and -Sunset must both be specified together.' } if ($FromNightLight -and ($Sunrise -or $Sunset)) { throw '-FromNightLight cannot be combined with -Sunrise/-Sunset.' } # Runs the task using whichever PowerShell edition is currently running this cmdlet, since that's the # edition Lumos is guaranteed to be installed under - PS Core and Windows PowerShell have separate # module paths, so hardcoding the other edition's executable would fail to find Invoke-Lumos. $PSHOME # is the home directory of the CURRENT session, so this resolves to an exact, unambiguous full path # rather than relying on whatever "powershell.exe"/"pwsh.exe" happens to resolve to on PATH. $PowerShellExe = if ($PSVersionTable.PSEdition -eq 'Core') { Join-Path -Path $PSHOME -ChildPath 'pwsh.exe' } else { Join-Path -Path $PSHOME -ChildPath 'powershell.exe' } # -WindowStyle Hidden keeps the task running silently in the background with no visible console window. $ArgumentDefaults = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden' # -NoProfile means the task's fresh PowerShell process has to discover Lumos via module auto-loading, # which depends on $env:PSModulePath being set up the same way in that process as it is interactively - # not guaranteed for a process spawned by Task Scheduler rather than typed at a prompt. Importing from # this exact, already-loaded module's own path sidesteps auto-loading entirely. $ModulePath = (Get-Module -Name 'Lumos').Path $ImportModuleCommand = "Import-Module '$ModulePath' -Force; " $LumosArgument = "$ArgumentDefaults -Command ${ImportModuleCommand}Invoke-Lumos" If ($ExcludeSystem) { $LumosArgument = $LumosArgument + " -ExcludeSystem" } If ($RestartExplorer) { $LumosArgument = $LumosArgument + " -RestartExplorer" } If ($ExcludeApps) { $LumosArgument = $LumosArgument + " -ExcludeApps" } If ($IncludeOfficeProPlus) { $LumosArgument = $LumosArgument + " -IncludeOfficeProPlus" } If ($LightWallpaper) { $LumosArgument = $LumosArgument + " -LightWallpaper '$LightWallpaper'" } If ($DarkWallpaper) { $LumosArgument = $LumosArgument + " -DarkWallpaper '$DarkWallpaper'" } # A user-specified -Sunrise/-Sunset pair, or -FromNightLight, is used as-is, skipping the location/daylight # lookup entirely - since neither of those drift with the season the way an automatic lookup's result # does, the "Lumos-Maintenance" task isn't needed either. $UseCustomDaylight = ($Sunrise -and $Sunset) -or $FromNightLight if ($FromNightLight) { $DayLight = Get-NightLightSchedule $ScheduleSource = 'Night Light' } elseif ($UseCustomDaylight) { $DayLight = [pscustomobject]@{ Sunrise = $Sunrise; Sunset = $Sunset } $ScheduleSource = 'Custom' } else { $UserLocation = Get-UserLocation if (-not $UserLocation) { throw 'Could not get sunrise/sunset data for the current user.' } $DayLight = Get-LocalDaylight -Latitude $UserLocation.Latitude -Longitude $UserLocation.Longitude $ScheduleSource = 'Location' } $LumosAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $LumosArgument $Principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive $TaskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable $SunriseTrigger = New-ScheduledTaskTrigger -Daily -At $DayLight.Sunrise $SunsetTrigger = New-ScheduledTaskTrigger -Daily -At $DayLight.Sunset $LumosTask = New-ScheduledTask -Action $LumosAction -Principal $Principal -Settings $TaskSettings -Trigger @($SunriseTrigger, $SunsetTrigger) | Register-ScheduledTask -TaskName 'Lumos' -Force # Inserting a custom type name (rather than replacing it) lets the Lumos.Format.ps1xml view below add a # Source/Schedule summary to how this displays, without losing anything Register-ScheduledTask's own # MSFT_ScheduledTask type gives it (e.g. so Unregister-ScheduledTask -InputObject $task still works). $LumosTask.PSObject.TypeNames.Insert(0, 'Lumos.ScheduledTask') Add-Member -InputObject $LumosTask -NotePropertyName 'ScheduleSource' -NotePropertyValue $ScheduleSource Add-Member -InputObject $LumosTask -NotePropertyName 'Schedule' -NotePropertyValue ( "Light $($DayLight.Sunrise.ToString('HH:mm')), Dark $($DayLight.Sunset.ToString('HH:mm'))" ) if ($UseCustomDaylight) { # A fixed schedule doesn't need weekly upkeep - remove any "Lumos-Maintenance" task left over from a # previous registration with the automatic lookup, since it would otherwise keep overwriting these # fixed trigger times with a freshly looked-up sunrise/sunset every week. if (Get-ScheduledTask -TaskName 'Lumos-Maintenance' -ErrorAction SilentlyContinue) { Write-Verbose 'Removing existing Lumos-Maintenance task, since it is not needed for a fixed schedule..' Unregister-ScheduledTask -TaskName 'Lumos-Maintenance' -Confirm:$false } return $LumosTask } $MaintenanceArgument = "$ArgumentDefaults -Command ${ImportModuleCommand}Update-LumosScheduledTask" $MaintenanceAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $MaintenanceArgument $MaintenancePrincipal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive $MaintenanceSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable # Runs weekly (on whichever day this cmdlet happens to be run) at solar noon: the point in the day # furthest from both the sunrise and sunset triggers above, to minimize any chance of # Update-LumosScheduledTask trying to modify the "Lumos" task while it's running. Weekly is frequent # enough to keep the sunrise/sunset triggers reasonably current without a daily API call. $SolarNoon = $DayLight.Sunrise.AddSeconds(($DayLight.Sunset - $DayLight.Sunrise).TotalSeconds / 2) $MaintenanceTrigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek (Get-Date).DayOfWeek -At $SolarNoon $MaintenanceTask = New-ScheduledTask -Action $MaintenanceAction -Principal $MaintenancePrincipal -Settings $MaintenanceSettings -Trigger $MaintenanceTrigger | Register-ScheduledTask -TaskName 'Lumos-Maintenance' -Force $MaintenanceTask.PSObject.TypeNames.Insert(0, 'Lumos.ScheduledTask') Add-Member -InputObject $MaintenanceTask -NotePropertyName 'ScheduleSource' -NotePropertyValue $ScheduleSource Add-Member -InputObject $MaintenanceTask -NotePropertyName 'Schedule' -NotePropertyValue ( "Weekly $((Get-Date).DayOfWeek) $($SolarNoon.ToString('HH:mm'))" ) $LumosTask, $MaintenanceTask } Function Update-LumosScheduledTask { <# .SYNOPSIS Updates the "Lumos" scheduled task's sunrise/sunset trigger times to match today. .DESCRIPTION Recomputes the current sunrise/sunset for the local user and updates the "Lumos" scheduled task's two daily triggers to match, so they stay aligned with sunrise/sunset as they drift through the year. The task's existing action, principal and settings are read back and reused as-is, so only the triggers actually change - but rather than modifying the task in place, it's unregistered and re-registered with those same values plus the new triggers. This is intended to be run automatically, once weekly, by the "Lumos-Maintenance" scheduled task that Register-LumosScheduledTask creates alongside the "Lumos" task itself - not normally called directly. It's registered as a separate task, run at a time unlikely to overlap with "Lumos" actually running, because Windows Task Scheduler won't let a task modify its own definition while it's the active running instance. .EXAMPLE Update-LumosScheduledTask Updates the "Lumos" scheduled task's triggers to today's sunrise/sunset for the current location. #> [CmdletBinding(SupportsShouldProcess)] Param() if (-not ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows)) { Write-Warning 'Update-LumosScheduledTask is only supported on Windows.' return } $LumosTask = Get-ScheduledTask -TaskName 'Lumos' -ErrorAction SilentlyContinue if (-not $LumosTask) { Write-Warning "No 'Lumos' scheduled task was found. Run Register-LumosScheduledTask first." return } $UserLocation = Get-UserLocation if (-not $UserLocation) { Write-Error 'Could not get sunrise/sunset data for the current user.' return } $DayLight = Get-LocalDaylight -Latitude $UserLocation.Latitude -Longitude $UserLocation.Longitude $SunriseTrigger = New-ScheduledTaskTrigger -Daily -At $DayLight.Sunrise $SunsetTrigger = New-ScheduledTaskTrigger -Daily -At $DayLight.Sunset # Rebuilds the task from its own existing Action/Principal/Settings (reused as-is) plus the new # triggers, then unregisters and re-registers it, rather than modifying it in place with # Set-ScheduledTask - delete-and-recreate uses the same Register-ScheduledTask call already proven to # work in Register-LumosScheduledTask, in case Set-ScheduledTask specifically is what's unreliable here. $NewTaskDefinition = New-ScheduledTask -Action $LumosTask.Actions -Principal $LumosTask.Principal ` -Settings $LumosTask.Settings -Trigger @($SunriseTrigger, $SunsetTrigger) if ($PSCmdlet.ShouldProcess('Lumos scheduled task', 'Recreate with updated sunrise/sunset triggers')) { Unregister-ScheduledTask -TaskName 'Lumos' -Confirm:$false Register-ScheduledTask -TaskName 'Lumos' -InputObject $NewTaskDefinition | Out-Null } } if (-not (Test-Path alias:lumos)) { New-Alias -Name 'lumos' -Value 'Invoke-Lumos' Export-ModuleMember -Alias 'lumos' } Function Get-LocalDaylight { <# .SYNOPSIS Returns the current sunrise and sunset times for the local user in localtime. .EXAMPLE Get-LocalDaylight Result ----------- Sunrise : 06/08/2019 06:04:57 Sunset : 06/08/2019 20:22:17 #> [cmdletbinding()] Param( [Parameter(Mandatory)] [double] $Latitude, [Parameter(Mandatory)] [double] $Longitude ) # Return sunrise/sunset $Daylight = (Invoke-RestMethod "https://api.sunrise-sunset.org/json?lat=$Latitude&lng=$Longitude").results # Convert to local time datetime objects [pscustomobject]@{ Sunrise = ($Daylight.Sunrise | Get-Date).ToLocalTime() Sunset = ($Daylight.Sunset | Get-Date).ToLocalTime() } } Function Get-NightLightSchedule { <# .SYNOPSIS Returns today's Sunrise/Sunset as configured in Windows' own Night Light schedule. .DESCRIPTION Reads the schedule Windows Night Light is currently configured with (Settings > System > Display > Night light) directly from the registry, so Register-LumosScheduledTask can reuse it instead of looking up sunrise/sunset for your location independently. Night Light stores its settings as a Microsoft Bond CompactBinary v1 document, nested inside an outer "CloudStore" envelope of the same format, under: HKCU:\Software\Microsoft\Windows\CurrentVersion\CloudStore\Store\DefaultAccount\Current\ default$windows.data.bluelightreduction.settings\windows.data.bluelightreduction.settings ("Data"). This isn't a documented or supported format - it's reverse-engineered (field IDs/purposes below are cross-referenced against https://github.com/kvnxiao/win-nightlight-cli's docs and verified against real registry data) and could change in a future Windows release. Rather than parse the outer envelope's own (undocumented) schema, the inner document is located by its own magic header (0x43 0x42 0x01 0x00, "CB" + version 1), which empirically always reappears a little way into it. The inner document's fields that matter here: 0 (bool) schedule_enabled - a schedule is active 10 (bool) set_hours_mode - PRESENT (any value) when using a fixed "Set hours" schedule 20 (TimeBlock) schedule_start_time - fixed schedule's dark/Night-Light-on time 30 (TimeBlock) schedule_end_time - fixed schedule's light/Night-Light-off time 50 (TimeBlock) sunset_time - "Sunset to sunrise" mode's computed dark/Night-Light-on time 60 (TimeBlock) sunrise_time - "Sunset to sunrise" mode's computed light/Night-Light-off time A TimeBlock is itself a struct of two optional int8 fields (0 = hour, 1 = minute); Bond omits fields left at their default value, so an empty TimeBlock means midnight - indistinguishable from that field being genuinely unset. Field 10's presence (not its actual bool value) is what indicates "Set hours" mode is selected; without it, fields 50/60 are used instead, if a schedule is enabled. .EXAMPLE Get-NightLightSchedule Result ----------- Sunrise : 06/08/2019 07:00:00 Sunset : 06/08/2019 21:00:00 #> [CmdletBinding()] Param() $RegistryPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\CloudStore\Store\DefaultAccount\Current\' + 'default$windows.data.bluelightreduction.settings\windows.data.bluelightreduction.settings' $Bytes = (Get-ItemProperty -Path $RegistryPath -Name 'Data' -ErrorAction SilentlyContinue).Data if (-not $Bytes) { throw 'Could not read the Windows Night Light schedule from the registry. Make sure Night Light has ' + 'been configured at least once under Settings > System > Display > Night light.' } $InnerStart = -1 for ($i = 4; $i -le $Bytes.Length - 4; $i++) { if ($Bytes[$i] -eq 0x43 -and $Bytes[$i + 1] -eq 0x42 -and $Bytes[$i + 2] -eq 0x01 -and $Bytes[$i + 3] -eq 0x00) { $InnerStart = $i + 4 break } } if ($InnerStart -lt 0) { throw 'Could not parse the Windows Night Light schedule - the registry data was not in the expected format.' } $Position = $InnerStart $ScheduleEnabled = $false $SetHoursMode = $false $Times = @{} while ($Position -lt $Bytes.Length) { $Header = $Bytes[$Position] $Position++ # Low 5 bits are the Bond wire type; high 3 bits are either the field ID directly (0-5), or a # sentinel saying the ID follows as its own byte (6) or 16-bit value (7). $Type = $Header -band 0x1F $IdBits = ($Header -shr 5) -band 0x07 if ($Type -eq 0) { break } # BT_STOP: end of this struct if ($Type -eq 1) { continue } # BT_STOP_BASE: end of base class fields, more fields follow # Cast to [int] in every branch - $Times.ContainsKey(20) below relies on the key's boxed type matching # an int literal, which a bare [byte] (from $IdBits or $Bytes[$Position]) would silently fail to match. $FieldId = if ($IdBits -le 5) { [int]$IdBits } elseif ($IdBits -eq 6) { $Id = [int]$Bytes[$Position]; $Position++; $Id } else { $Id = [int][BitConverter]::ToUInt16($Bytes, $Position); $Position += 2; $Id } switch ($Type) { 2 { # BT_BOOL $Value = $Bytes[$Position] -ne 0 $Position++ if ($FieldId -eq 0) { $ScheduleEnabled = $Value } elseif ($FieldId -eq 10) { $SetHoursMode = $true } } { $_ -in 3, 14 } { # BT_UINT8 / BT_INT8 - 1 byte $Position++ } { $_ -in 4, 5, 6, 15, 16, 17 } { # BT_UINT16/32/64, BT_INT16/32/64 - varint, e.g. the color_temperature field do { $VarIntByte = $Bytes[$Position]; $Position++ } while ($VarIntByte -band 0x80) } 10 { # BT_STRUCT if ($FieldId -in 20, 30, 50, 60) { # A TimeBlock: hour (field 0) and minute (field 1), each an optional BT_INT8. $Hour = 0 $Minute = 0 while ($true) { $SubHeader = $Bytes[$Position]; $Position++ $SubType = $SubHeader -band 0x1F $SubIdBits = ($SubHeader -shr 5) -band 0x07 if ($SubType -eq 0) { break } if ($SubType -eq 1) { continue } if ($SubType -ne 14) { throw "Could not parse the Windows Night Light schedule - unexpected field type ($SubType) in a TimeBlock." } $SubId = if ($SubIdBits -le 5) { $SubIdBits } else { $Id = $Bytes[$Position]; $Position++; $Id } $Value = $Bytes[$Position]; $Position++ if ($SubId -eq 0) { $Hour = $Value } elseif ($SubId -eq 1) { $Minute = $Value } } $Times[$FieldId] = [pscustomobject]@{ Hour = $Hour; Minute = $Minute } } else { # An unrecognized struct field - skip it, as long as it's flat (no further nested structs). while ($true) { $SubHeader = $Bytes[$Position]; $Position++ $SubType = $SubHeader -band 0x1F if ($SubType -eq 0) { break } if ($SubType -eq 1) { continue } throw "Could not parse the Windows Night Light schedule - encountered an unsupported nested field ($SubType)." } } } default { throw "Could not parse the Windows Night Light schedule - encountered an unsupported field type ($Type)." } } } if ($SetHoursMode -and $Times.ContainsKey(20) -and $Times.ContainsKey(30)) { $SunsetTime = $Times[20] $SunriseTime = $Times[30] } elseif ($ScheduleEnabled -and $Times.ContainsKey(50) -and $Times.ContainsKey(60) -and -not ($Times[50].Hour -eq 0 -and $Times[50].Minute -eq 0 -and $Times[60].Hour -eq 0 -and $Times[60].Minute -eq 0)) { $SunsetTime = $Times[50] $SunriseTime = $Times[60] } else { throw 'Windows Night Light does not currently have a schedule configured. Enable a schedule under ' + 'Settings > System > Display > Night light, or use -Sunrise/-Sunset instead.' } $Today = Get-Date [pscustomobject]@{ Sunrise = Get-Date -Year $Today.Year -Month $Today.Month -Day $Today.Day -Hour $SunriseTime.Hour -Minute $SunriseTime.Minute -Second 0 Sunset = Get-Date -Year $Today.Year -Month $Today.Month -Day $Today.Day -Hour $SunsetTime.Hour -Minute $SunsetTime.Minute -Second 0 } } Function Get-UserLocation { <# .SYNOPSIS Returns the approximate location of the local user, based on their public IP address. .DESCRIPTION Looks up the city-level location of the current public IP address via the ipinfo.io API. This is used instead of the Windows Location Service because that requires location permission to be granted interactively and is typically unavailable when this module is run from a Scheduled Task. .EXAMPLE Get-UserLocation Result ----------- Latitude : 51.5074 Longitude : -0.1278 #> [cmdletbinding()] Param() $IPInfo = Invoke-RestMethod -Uri 'https://ipinfo.io/json' if ($IPInfo.loc) { $Latitude, $Longitude = $IPInfo.loc -split ',' [pscustomobject]@{ Latitude = [double]$Latitude Longitude = [double]$Longitude } } } Function Invoke-AppleScript { <# .SYNOPSIS Executes a command string via Apple Script. #> [cmdletbinding(SupportsShouldProcess)] param( [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [String] $Command ) Begin { } Process { If ($PSCmdlet.ShouldProcess('/usr/bin/osascript -e',$Command)){ /usr/bin/osascript -e $Command } } } Function Send-SettingChangeMessage { <# .SYNOPSIS Broadcasts a WM_SETTINGCHANGE message so running apps and the shell pick up a theme change. .DESCRIPTION Sends the same "ImmersiveColorSet" WM_SETTINGCHANGE broadcast Windows itself sends when a theme setting is changed via Settings, so apps and the shell refresh live - without restarting Explorer. .EXAMPLE Send-SettingChangeMessage #> [CmdletBinding()] Param() # Guarded so a second call within the same session (e.g. toggling the theme more than once interactively) # doesn't hit Add-Type's "type already exists" error. if (-not ('Win32SettingChange' -as [type])) { Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public class Win32SettingChange { [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); } "@ } $HWND_BROADCAST = [IntPtr]0xffff $WM_SETTINGCHANGE = 0x1A $SMTO_ABORTIFHUNG = 0x0002 $Result = [UIntPtr]::Zero [void][Win32SettingChange]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, 'ImmersiveColorSet', $SMTO_ABORTIFHUNG, 5000, [ref]$Result) } Function Set-Wallpaper { <# .SYNOPSIS Applies a specified wallpaper to the current user's desktop .PARAMETER Image Provide the full path to the image .EXAMPLE Set-WallPaper -Image "C:\Wallpaper\Default.jpg" #> [cmdletbinding(SupportsShouldProcess)] Param( [string] $Image ) Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public class Params { [DllImport("User32.dll",CharSet=CharSet.Unicode)] public static extern int SystemParametersInfo (Int32 uAction, Int32 uParam, String lpvParam, Int32 fuWinIni); } "@ $SPI_SETDESKWALLPAPER = 0x0014 $UpdateIniFile = 0x01 $SendChangeEvent = 0x02 $fWinIni = $UpdateIniFile -bor $SendChangeEvent if ($PSCmdlet.ShouldProcess($Image)) { [void][Params]::SystemParametersInfo($SPI_SETDESKWALLPAPER, 0, $Image, $fWinIni) } } |