diff --git a/docs/customization.md b/docs/customization.md index 9a8080bc..8699aa60 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -292,6 +292,45 @@ rationales, citations, evidence grades, and reversal guidance next to each desired value. Tests require those fields for every applied registry setting so this audit context cannot silently drift away from the implementation. +## Windows Night Light + +On Windows, `run_onchange_41-set-night-light.ps1` enables Night Light on a +sunset-to-sunrise schedule at strength `50`. Pass `-Strength` (0-100) to the +script to use a different intensity. + +Night Light has no supported configuration API. Windows persists it as two +`REG_BINARY` CloudStore values under `HKEY_CURRENT_USER`: + +| Value | Contents | +| ----- | -------- | +| `...\default$windows.data.bluelightreduction.settings\windows.data.bluelightreduction.settings` | Schedule mode, colour temperature, schedule times, cached sunset/sunrise times | +| `...\default$windows.data.bluelightreduction.bluelightreductionstate\windows.data.bluelightreduction.bluelightreductionstate` | Whether Night Light is currently on | + +Both are [Microsoft Bond CompactBinary v1][bond] payloads inside a CloudStore +envelope. The script implements just enough of that codec to decode the +existing blobs, change the fields it owns, and re-encode them, so unrelated +data (notably the sunset/sunrise times Windows computes from your location) is +preserved byte for byte. Evidence grade 3: the format is +[reverse-engineered and community-documented][fmt], not a Microsoft contract. + +Schedule mode is encoded by field presence rather than a value: field `0` +(`schedule_enabled`) is set to `true` and field `10` (`set_hours_mode`) is +omitted, which is how Windows represents "Sunset to sunrise". Strength maps +linearly onto colour temperature, where `0` is 6500 K (no effect) and `100` is +1200 K, so strength `50` stores 3850 K. + +The state value is derived rather than forced: the script turns Night Light on +only when the current time falls inside the cached sunset-to-sunrise window, +matching what Windows itself would have done. All writes stay in +`HKEY_CURRENT_USER` and the script is idempotent — a second run reports zero +changes. + +Reversal: open Settings > System > Display > Night light and turn it off, or +delete the two registry values above and sign out. + +[bond]: https://github.com/microsoft/bond +[fmt]: https://github.com/kvnxiao/win-nightlight-cli/blob/main/docs/nightlight-registry-format.md + ## Learn More - [Chezmoi documentation](https://www.chezmoi.io/user-guide/command-overview/) diff --git a/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 b/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 new file mode 100644 index 00000000..b88a6403 --- /dev/null +++ b/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 @@ -0,0 +1,617 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Configures Windows Night Light for the invoking user. + +.DESCRIPTION + Night Light has no supported API. Windows persists it as two REG_BINARY + CloudStore blobs encoded with Microsoft Bond CompactBinary v1: + + HKCU\...\CloudStore\Store\DefaultAccount\Current\ + default$windows.data.bluelightreduction.settings\... (schedule + strength) + default$windows.data.bluelightreduction.bluelightreductionstate\... (on/off) + + This script decodes the existing blobs, applies the desired schedule mode + and strength, and writes them back. Sunset/sunrise times computed by + Windows are preserved so the shell does not have to recompute them. + + Format reference (reverse-engineered, community-documented): + https://github.com/kvnxiao/win-nightlight-cli/blob/main/docs/nightlight-registry-format.md + +.PARAMETER Strength + Night Light strength as shown in Settings (0-100). Maps linearly onto the + stored colour temperature: 0 => 6500 K (no effect), 100 => 1200 K. + +.PARAMETER SkipApply + Dot-source the functions without applying anything (used by tests). +#> + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "PSAvoidUsingWriteHost", + "", + Justification = "Matches existing chezmoi setup script progress output." +)] +[CmdletBinding(SupportsShouldProcess)] +param( + [ValidateRange(0, 100)] + [int]$Strength = 50, + + [switch]$SkipApply +) + +$ErrorActionPreference = "Stop" + +$script:NightLightStoreRoot = "Software\Microsoft\Windows\CurrentVersion\CloudStore\Store\DefaultAccount\Current" +$script:NightLightSettingsPath = "$script:NightLightStoreRoot\default`$windows.data.bluelightreduction.settings\windows.data.bluelightreduction.settings" +$script:NightLightStatePath = "$script:NightLightStoreRoot\default`$windows.data.bluelightreduction.bluelightreductionstate\windows.data.bluelightreduction.bluelightreductionstate" + +# Colour temperature bounds of the Settings strength slider. +$script:NightLightMaxKelvin = 6500 +$script:NightLightMinKelvin = 1200 + +#region Bond CompactBinary v1 primitives + +function ConvertTo-BondVarint { + [OutputType([byte[]])] + param([Parameter(Mandatory)][uint64]$Value) + + $bytes = [System.Collections.Generic.List[byte]]::new() + do { + $chunk = [byte]($Value -band 0x7F) + $Value = $Value -shr 7 + if ($Value -ne 0) { + $chunk = [byte]($chunk -bor 0x80) + } + $bytes.Add($chunk) + } while ($Value -ne 0) + + return , $bytes.ToArray() +} + +function ConvertFrom-BondVarint { + param( + [Parameter(Mandatory)][byte[]]$Bytes, + [Parameter(Mandatory)][int]$Offset + ) + + [uint64]$value = 0 + $shift = 0 + $index = $Offset + + while ($true) { + if ($index -ge $Bytes.Length) { + throw "Truncated varint at offset $Offset." + } + + $current = $Bytes[$index] + $index++ + $value = $value -bor ([uint64]($current -band 0x7F) -shl $shift) + if (($current -band 0x80) -eq 0) { + break + } + + $shift += 7 + if ($shift -gt 63) { + throw "Varint at offset $Offset exceeds 64 bits." + } + } + + return [pscustomobject]@{ Value = $value; NextOffset = $index } +} + +function ConvertTo-BondZigZag { + [OutputType([uint64])] + param([Parameter(Mandatory)][int64]$Value) + + $encoded = ($Value -shl 1) -bxor ($Value -shr 63) + return [BitConverter]::ToUInt64([BitConverter]::GetBytes($encoded), 0) +} + +function ConvertFrom-BondZigZag { + [OutputType([int64])] + param([Parameter(Mandatory)][uint64]$Value) + + $half = [int64]($Value -shr 1) + if (($Value -band 1) -eq 1) { + return -($half + 1) + } + + return $half +} + +<# + Field headers pack the Bond type into the low 5 bits and the field id into + the high 3 bits. Ids >= 6 spill into one (id <= 255) or two extra bytes. +#> +function ConvertTo-BondFieldHeader { + [OutputType([byte[]])] + param( + [Parameter(Mandatory)][int]$FieldId, + [Parameter(Mandatory)][int]$BondType + ) + + if ($FieldId -lt 6) { + return , [byte[]]@([byte](($FieldId -shl 5) -bor $BondType)) + } + + if ($FieldId -le 255) { + return , [byte[]]@([byte](0xC0 -bor $BondType), [byte]$FieldId) + } + + return , [byte[]]@( + [byte](0xE0 -bor $BondType), + [byte]($FieldId -band 0xFF), + [byte](($FieldId -shr 8) -band 0xFF) + ) +} + +function ConvertFrom-BondFieldHeader { + param( + [Parameter(Mandatory)][byte[]]$Bytes, + [Parameter(Mandatory)][int]$Offset + ) + + $raw = $Bytes[$Offset] + $bondType = $raw -band 0x1F + $idBits = ($raw -shr 5) -band 0x07 + + switch ($idBits) { + 6 { + return [pscustomobject]@{ FieldId = [int]$Bytes[$Offset + 1]; BondType = $bondType; NextOffset = $Offset + 2 } + } + 7 { + $id = [int]$Bytes[$Offset + 1] -bor ([int]$Bytes[$Offset + 2] -shl 8) + return [pscustomobject]@{ FieldId = $id; BondType = $bondType; NextOffset = $Offset + 3 } + } + default { + return [pscustomobject]@{ FieldId = $idBits; BondType = $bondType; NextOffset = $Offset + 1 } + } + } +} + +#endregion + +#region CloudStore wrapper + +function ConvertFrom-CloudStoreBlob { + <# + .SYNOPSIS + Extracts the inner Bond payload from a CloudStore wrapper blob. + #> + param([Parameter(Mandatory)][byte[]]$Blob) + + if ($Blob.Length -lt 20) { + throw "CloudStore blob is too short ($($Blob.Length) bytes)." + } + + # 43 42 01 00 = marshaled CB v1 header; 0A 02 01 00 = metadata struct; + # 2A 06 = payload container with Unix timestamp. + if ($Blob[0] -ne 0x43 -or $Blob[1] -ne 0x42 -or $Blob[2] -ne 0x01 -or $Blob[3] -ne 0x00) { + throw "CloudStore blob does not start with the marshaled CB v1 header." + } + + $offset = 4 + if ($Blob[$offset] -ne 0x0A -or $Blob[$offset + 1] -ne 0x02 -or $Blob[$offset + 3] -ne 0x00) { + throw "Unexpected CloudStore metadata struct." + } + $offset += 4 + + if ($Blob[$offset] -ne 0x2A -or $Blob[$offset + 1] -ne 0x06) { + throw "Unexpected CloudStore payload container." + } + $offset += 2 + + $timestamp = ConvertFrom-BondVarint -Bytes $Blob -Offset $offset + $offset = $timestamp.NextOffset + + if ($Blob[$offset] -ne 0x2A -or $Blob[$offset + 1] -ne 0x2B -or $Blob[$offset + 2] -ne 0x0E) { + throw "Unexpected CloudStore data wrapper." + } + $offset += 3 + + $count = ConvertFrom-BondVarint -Bytes $Blob -Offset $offset + $offset = $count.NextOffset + $length = [int]$count.Value + + if (($offset + $length) -gt $Blob.Length) { + throw "CloudStore payload length ($length) exceeds blob size." + } + + $payload = [byte[]]::new($length) + [Array]::Copy($Blob, $offset, $payload, 0, $length) + + return [pscustomobject]@{ + Timestamp = $timestamp.Value + Payload = $payload + } +} + +function ConvertTo-CloudStoreBlob { + <# + .SYNOPSIS + Wraps an inner Bond payload in the CloudStore envelope. + #> + [OutputType([byte[]])] + param( + [Parameter(Mandatory)][byte[]]$Payload, + [uint64]$Timestamp = [uint64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + ) + + $bytes = [System.Collections.Generic.List[byte]]::new() + $bytes.AddRange([byte[]]@(0x43, 0x42, 0x01, 0x00)) + $bytes.AddRange([byte[]]@(0x0A, 0x02, 0x01, 0x00)) + $bytes.AddRange([byte[]]@(0x2A, 0x06)) + $bytes.AddRange((ConvertTo-BondVarint -Value $Timestamp)) + $bytes.AddRange([byte[]]@(0x2A, 0x2B, 0x0E)) + $bytes.AddRange((ConvertTo-BondVarint -Value ([uint64]$Payload.Length))) + $bytes.AddRange($Payload) + $bytes.AddRange([byte[]]@(0x00, 0x00, 0x00)) + + return , $bytes.ToArray() +} + +#endregion + +#region Night Light schema + +function ConvertTo-NightLightColorTemperature { + <# + .SYNOPSIS + Converts a 0-100 Settings strength value to Kelvin. + #> + [OutputType([int])] + param([Parameter(Mandatory)][ValidateRange(0, 100)][int]$Strength) + + $span = $script:NightLightMaxKelvin - $script:NightLightMinKelvin + return [int]($script:NightLightMaxKelvin - [Math]::Round($span * $Strength / 100.0)) +} + +function ConvertFrom-NightLightColorTemperature { + [OutputType([int])] + param([Parameter(Mandatory)][int]$Kelvin) + + $span = $script:NightLightMaxKelvin - $script:NightLightMinKelvin + return [int][Math]::Round(($script:NightLightMaxKelvin - $Kelvin) * 100.0 / $span) +} + +function Read-NightLightTimeBlock { + param( + [Parameter(Mandatory)][byte[]]$Bytes, + [Parameter(Mandatory)][int]$Offset + ) + + $hour = 0 + $minute = 0 + $index = $Offset + + while ($index -lt $Bytes.Length -and $Bytes[$index] -ne 0x00) { + $header = ConvertFrom-BondFieldHeader -Bytes $Bytes -Offset $index + $index = $header.NextOffset + + # BT_INT8 is stored as a single raw byte, not as a zigzag varint. + $decoded = [int][sbyte]$Bytes[$index] + $index++ + + switch ($header.FieldId) { + 0 { $hour = $decoded } + 1 { $minute = $decoded } + } + } + + return [pscustomobject]@{ + Hour = $hour + Minute = $minute + NextOffset = $index + 1 + } +} + +function Write-NightLightTimeBlock { + [OutputType([byte[]])] + param( + [Parameter(Mandatory)][int]$FieldId, + [Parameter(Mandatory)][int]$Hour, + [Parameter(Mandatory)][int]$Minute + ) + + $bytes = [System.Collections.Generic.List[byte]]::new() + $bytes.AddRange((ConvertTo-BondFieldHeader -FieldId $FieldId -BondType 0x0A)) + + # Bond omits fields that hold the default value (0). + if ($Hour -ne 0) { + $bytes.AddRange((ConvertTo-BondFieldHeader -FieldId 0 -BondType 0x0E)) + $bytes.Add([byte]$Hour) + } + if ($Minute -ne 0) { + $bytes.AddRange((ConvertTo-BondFieldHeader -FieldId 1 -BondType 0x0E)) + $bytes.Add([byte]$Minute) + } + + $bytes.Add(0x00) + return , $bytes.ToArray() +} + +function ConvertFrom-NightLightSettingsPayload { + param([Parameter(Mandatory)][byte[]]$Payload) + + $settings = [pscustomobject]@{ + ScheduleEnabled = $false + SetHoursMode = $false + StartHour = 0 + StartMinute = 0 + EndHour = 0 + EndMinute = 0 + ColorTemperature = $script:NightLightMaxKelvin + SunsetHour = 0 + SunsetMinute = 0 + SunriseHour = 0 + SunriseMinute = 0 + } + + $offset = 4 + while ($offset -lt $Payload.Length -and $Payload[$offset] -ne 0x00) { + $header = ConvertFrom-BondFieldHeader -Bytes $Payload -Offset $offset + $offset = $header.NextOffset + + switch ($header.FieldId) { + 0 { + $settings.ScheduleEnabled = $Payload[$offset] -ne 0x00 + $offset++ + } + 10 { + $settings.SetHoursMode = $true + $offset++ + } + 20 { + $block = Read-NightLightTimeBlock -Bytes $Payload -Offset $offset + $settings.StartHour = $block.Hour + $settings.StartMinute = $block.Minute + $offset = $block.NextOffset + } + 30 { + $block = Read-NightLightTimeBlock -Bytes $Payload -Offset $offset + $settings.EndHour = $block.Hour + $settings.EndMinute = $block.Minute + $offset = $block.NextOffset + } + 40 { + $value = ConvertFrom-BondVarint -Bytes $Payload -Offset $offset + $settings.ColorTemperature = [int](ConvertFrom-BondZigZag -Value $value.Value) + $offset = $value.NextOffset + } + 50 { + $block = Read-NightLightTimeBlock -Bytes $Payload -Offset $offset + $settings.SunsetHour = $block.Hour + $settings.SunsetMinute = $block.Minute + $offset = $block.NextOffset + } + 60 { + $block = Read-NightLightTimeBlock -Bytes $Payload -Offset $offset + $settings.SunriseHour = $block.Hour + $settings.SunriseMinute = $block.Minute + $offset = $block.NextOffset + } + default { + throw "Unknown Night Light settings field id $($header.FieldId)." + } + } + } + + return $settings +} + +function ConvertTo-NightLightSettingsPayload { + [OutputType([byte[]])] + param([Parameter(Mandatory)][psobject]$Settings) + + $bytes = [System.Collections.Generic.List[byte]]::new() + $bytes.AddRange([byte[]]@(0x43, 0x42, 0x01, 0x00)) + + if ($Settings.ScheduleEnabled) { + $bytes.AddRange([byte[]]@(0x02, 0x01)) + } + + # Field 10 is a presence flag: present => "Set hours", absent => solar. + if ($Settings.SetHoursMode) { + $bytes.AddRange((ConvertTo-BondFieldHeader -FieldId 10 -BondType 0x02)) + $bytes.Add(0x00) + } + + $bytes.AddRange((Write-NightLightTimeBlock -FieldId 20 -Hour $Settings.StartHour -Minute $Settings.StartMinute)) + $bytes.AddRange((Write-NightLightTimeBlock -FieldId 30 -Hour $Settings.EndHour -Minute $Settings.EndMinute)) + + $bytes.AddRange((ConvertTo-BondFieldHeader -FieldId 40 -BondType 0x0F)) + $bytes.AddRange((ConvertTo-BondVarint -Value (ConvertTo-BondZigZag -Value $Settings.ColorTemperature))) + + $bytes.AddRange((Write-NightLightTimeBlock -FieldId 50 -Hour $Settings.SunsetHour -Minute $Settings.SunsetMinute)) + $bytes.AddRange((Write-NightLightTimeBlock -FieldId 60 -Hour $Settings.SunriseHour -Minute $Settings.SunriseMinute)) + + $bytes.Add(0x00) + return , $bytes.ToArray() +} + +function ConvertFrom-NightLightStatePayload { + param([Parameter(Mandatory)][byte[]]$Payload) + + $state = [pscustomobject]@{ + Enabled = $false + LastTransitionFileTime = [uint64]0 + } + + $offset = 4 + while ($offset -lt $Payload.Length -and $Payload[$offset] -ne 0x00) { + $header = ConvertFrom-BondFieldHeader -Bytes $Payload -Offset $offset + $offset = $header.NextOffset + $value = ConvertFrom-BondVarint -Bytes $Payload -Offset $offset + $offset = $value.NextOffset + + switch ($header.FieldId) { + 0 { $state.Enabled = $true } + 10 { } + 20 { $state.LastTransitionFileTime = $value.Value } + default { throw "Unknown Night Light state field id $($header.FieldId)." } + } + } + + return $state +} + +function ConvertTo-NightLightStatePayload { + [OutputType([byte[]])] + param( + [Parameter(Mandatory)][bool]$Enabled, + [uint64]$FileTime = 0 + ) + + if ($FileTime -eq 0) { + $FileTime = [uint64][DateTime]::UtcNow.ToFileTimeUtc() + } + + $bytes = [System.Collections.Generic.List[byte]]::new() + $bytes.AddRange([byte[]]@(0x43, 0x42, 0x01, 0x00)) + + # Field 0 is a presence flag: present => forced on, absent => follow schedule. + if ($Enabled) { + $bytes.AddRange((ConvertTo-BondFieldHeader -FieldId 0 -BondType 0x10)) + $bytes.Add(0x00) + } + + $bytes.AddRange((ConvertTo-BondFieldHeader -FieldId 10 -BondType 0x10)) + $bytes.AddRange((ConvertTo-BondVarint -Value (ConvertTo-BondZigZag -Value 1))) + + $bytes.AddRange((ConvertTo-BondFieldHeader -FieldId 20 -BondType 0x06)) + $bytes.AddRange((ConvertTo-BondVarint -Value $FileTime)) + + $bytes.Add(0x00) + return , $bytes.ToArray() +} + +function Test-NightLightWithinNightWindow { + <# + .SYNOPSIS + True when the reference time falls in the sunset..sunrise window. + #> + [OutputType([bool])] + param( + [Parameter(Mandatory)][psobject]$Settings, + [DateTime]$Now = (Get-Date) + ) + + $current = $Now.Hour * 60 + $Now.Minute + $sunset = $Settings.SunsetHour * 60 + $Settings.SunsetMinute + $sunrise = $Settings.SunriseHour * 60 + $Settings.SunriseMinute + + if ($sunset -eq $sunrise) { + return $false + } + + if ($sunset -gt $sunrise) { + # Window crosses midnight, which is the normal case. + return ($current -ge $sunset) -or ($current -lt $sunrise) + } + + return ($current -ge $sunset) -and ($current -lt $sunrise) +} + +#endregion + +function Set-NightLightConfiguration { + <# + .SYNOPSIS + Applies the desired Night Light schedule and strength. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [ValidateRange(0, 100)] + [int]$Strength = 50, + + [DateTime]$Now = (Get-Date), + + [scriptblock]$GetRegistryValue = { + param([string]$Path) + + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($Path, $false) + try { + if (-not $key) { + return $null + } + return $key.GetValue("Data", $null) + } + finally { + if ($key) { $key.Dispose() } + } + }, + + [scriptblock]$SetRegistryValue = { + param([string]$Path, [byte[]]$Value) + + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($Path) + try { + if (-not $key) { + throw "Unable to open or create HKCU\$Path." + } + $key.SetValue("Data", $Value, [Microsoft.Win32.RegistryValueKind]::Binary) + } + finally { + if ($key) { $key.Dispose() } + } + } + ) + + $results = @() + $desiredKelvin = ConvertTo-NightLightColorTemperature -Strength $Strength + + $settingsBlob = & $GetRegistryValue -Path $script:NightLightSettingsPath + if (-not $settingsBlob) { + throw "Night Light settings are not initialised. Open Settings > System > Display > Night light once, then re-run." + } + + $settings = ConvertFrom-NightLightSettingsPayload -Payload (ConvertFrom-CloudStoreBlob -Blob ([byte[]]$settingsBlob)).Payload + $settingsCorrect = $settings.ScheduleEnabled -and -not $settings.SetHoursMode -and $settings.ColorTemperature -eq $desiredKelvin + + if ($settingsCorrect) { + $results += [pscustomobject]@{ Setting = "Schedule and strength"; Status = "AlreadySet"; Changed = $false } + } + elseif ($PSCmdlet.ShouldProcess("HKCU\$script:NightLightSettingsPath", "Set sunset-to-sunrise schedule at strength $Strength ($desiredKelvin K)")) { + $settings.ScheduleEnabled = $true + $settings.SetHoursMode = $false + $settings.ColorTemperature = $desiredKelvin + + & $SetRegistryValue -Path $script:NightLightSettingsPath -Value (ConvertTo-CloudStoreBlob -Payload (ConvertTo-NightLightSettingsPayload -Settings $settings)) + $results += [pscustomobject]@{ Setting = "Schedule and strength"; Status = "Updated"; Changed = $true } + } + else { + $results += [pscustomobject]@{ Setting = "Schedule and strength"; Status = "WhatIf"; Changed = $false } + } + + $desiredState = Test-NightLightWithinNightWindow -Settings $settings -Now $Now + $stateBlob = & $GetRegistryValue -Path $script:NightLightStatePath + $currentState = $false + if ($stateBlob) { + $currentState = (ConvertFrom-NightLightStatePayload -Payload (ConvertFrom-CloudStoreBlob -Blob ([byte[]]$stateBlob)).Payload).Enabled + } + + if ($stateBlob -and $currentState -eq $desiredState) { + $results += [pscustomobject]@{ Setting = "Current state"; Status = "AlreadySet"; Changed = $false } + } + elseif ($PSCmdlet.ShouldProcess("HKCU\$script:NightLightStatePath", "Set Night Light to $(if ($desiredState) { 'on' } else { 'off' })")) { + & $SetRegistryValue -Path $script:NightLightStatePath -Value (ConvertTo-CloudStoreBlob -Payload (ConvertTo-NightLightStatePayload -Enabled $desiredState)) + $results += [pscustomobject]@{ Setting = "Current state"; Status = "Updated"; Changed = $true } + } + else { + $results += [pscustomobject]@{ Setting = "Current state"; Status = "WhatIf"; Changed = $false } + } + + return $results +} + +if (-not $SkipApply) { + if (-not $IsWindows) { + Write-Host "[SKIP] Night Light is a Windows-only setting." -ForegroundColor Yellow + return + } + + Write-Host "Applying Night Light configuration (sunset to sunrise, strength $Strength)..." -ForegroundColor Cyan + $results = @(Set-NightLightConfiguration -Strength $Strength -WhatIf:$WhatIfPreference) + $updatedCount = @($results | Where-Object { $_.Changed }).Count + Write-Host "[OK] Night Light configured ($updatedCount setting(s) changed)." -ForegroundColor Green +} diff --git a/tests/powershell/NightLight.Tests.ps1 b/tests/powershell/NightLight.Tests.ps1 new file mode 100644 index 00000000..5284b51f --- /dev/null +++ b/tests/powershell/NightLight.Tests.ps1 @@ -0,0 +1,379 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Tests the Night Light configuration script and its Bond CompactBinary codec. +#> + +BeforeAll { + $script:RepoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $script:ScriptPath = Join-Path $script:RepoRoot ` + "home\.chezmoiscripts\windows\run_onchange_41-set-night-light.ps1" + + . $script:ScriptPath -SkipApply + + # Captured from a live Windows 11 install: sunset-to-sunrise schedule, + # start 21:00, end 07:00, 3850 K, sunset 21:06, sunrise 06:25. + $script:SampleSettingsBlob = [byte[]]@( + 0x43, 0x42, 0x01, 0x00, 0x0a, 0x02, 0x01, 0x00, 0x2a, 0x06, 0xcf, 0xa2, + 0x88, 0xd4, 0x06, 0x2a, 0x2b, 0x0e, 0x23, 0x43, 0x42, 0x01, 0x00, 0x02, + 0x01, 0xca, 0x14, 0x0e, 0x15, 0x00, 0xca, 0x1e, 0x0e, 0x07, 0x00, 0xcf, + 0x28, 0x94, 0x3c, 0xca, 0x32, 0x0e, 0x15, 0x2e, 0x06, 0x00, 0xca, 0x3c, + 0x0e, 0x06, 0x2e, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00 + ) + + # Captured from the same install: Night Light force-enabled. + $script:SampleStateBlob = [byte[]]@( + 0x43, 0x42, 0x01, 0x00, 0x0a, 0x02, 0x01, 0x00, 0x2a, 0x06, 0xcd, 0xa1, + 0x88, 0xd4, 0x06, 0x2a, 0x2b, 0x0e, 0x15, 0x43, 0x42, 0x01, 0x00, 0x10, + 0x00, 0xd0, 0x0a, 0x02, 0xc6, 0x14, 0xef, 0x9a, 0xfa, 0xaf, 0xe4, 0xb6, + 0xcb, 0xee, 0x01, 0x00, 0x00, 0x00, 0x00 + ) + + # ConvertTo-* helpers return a byte[] as a single pipeline object, so compare + # via a hex string instead of letting Pester unroll the collection. + function script:Format-ByteHex { + param([byte[]]$Bytes) + return ($Bytes | ForEach-Object { $_.ToString('x2') }) -join ' ' + } + + function script:New-FakeNightLightRegistry { + param( + [byte[]]$SettingsBlob = $script:SampleSettingsBlob, + [byte[]]$StateBlob = $script:SampleStateBlob + ) + + $store = @{} + if ($SettingsBlob) { $store[$script:NightLightSettingsPath] = $SettingsBlob } + if ($StateBlob) { $store[$script:NightLightStatePath] = $StateBlob } + + return [pscustomobject]@{ + Store = $store + Writes = [System.Collections.Generic.List[string]]::new() + } + } +} + +Describe "Night Light script" -Tag "Unit" { + It "exists and has valid PowerShell syntax" { + $script:ScriptPath | Should -Exist + + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $script:ScriptPath, + [ref]$null, + [ref]$errors + ) | Out-Null + + $errors | Should -BeNullOrEmpty + } + + It "is a non-template Windows script" { + $script:ScriptPath | Should -Match '\.ps1$' + $script:ScriptPath | Should -Not -Match '\.tmpl$' + } + + It "contains no non-ASCII characters" { + $content = Get-Content -Path $script:ScriptPath -Raw + [regex]::Matches($content, '[^\x00-\x7F]').Count | Should -Be 0 + } + + It "targets the documented CloudStore registry locations" { + $script:NightLightSettingsPath | + Should -Be 'Software\Microsoft\Windows\CurrentVersion\CloudStore\Store\DefaultAccount\Current\default$windows.data.bluelightreduction.settings\windows.data.bluelightreduction.settings' + $script:NightLightStatePath | + Should -Be 'Software\Microsoft\Windows\CurrentVersion\CloudStore\Store\DefaultAccount\Current\default$windows.data.bluelightreduction.bluelightreductionstate\windows.data.bluelightreduction.bluelightreductionstate' + } +} + +Describe "Bond CompactBinary primitives" -Tag "Unit" { + It "round-trips varints" -ForEach @( + @{ Value = [uint64]0 } + @{ Value = [uint64]1 } + @{ Value = [uint64]127 } + @{ Value = [uint64]128 } + @{ Value = [uint64]7700 } + @{ Value = [uint64]1742540908 } + @{ Value = [uint64]134313824772590959 } + ) { + $encoded = ConvertTo-BondVarint -Value $Value + (ConvertFrom-BondVarint -Bytes $encoded -Offset 0).Value | Should -Be $Value + } + + It "encodes 7700 as the two-byte varint 0x94 0x3C" { + script:Format-ByteHex (ConvertTo-BondVarint -Value ([uint64]7700)) | Should -Be "94 3c" + } + + It "round-trips zigzag values" -ForEach @( + @{ Value = [int64]0 } + @{ Value = [int64]1 } + @{ Value = [int64]-1 } + @{ Value = [int64]2790 } + @{ Value = [int64]-3850 } + ) { + ConvertFrom-BondZigZag -Value (ConvertTo-BondZigZag -Value $Value) | Should -Be $Value + } + + It "encodes 2790 K as the documented zigzag varint" { + script:Format-ByteHex (ConvertTo-BondVarint -Value (ConvertTo-BondZigZag -Value 2790)) | + Should -Be "cc 2b" + } + + It "packs small field ids into a single header byte" { + script:Format-ByteHex (ConvertTo-BondFieldHeader -FieldId 0 -BondType 0x02) | Should -Be "02" + script:Format-ByteHex (ConvertTo-BondFieldHeader -FieldId 1 -BondType 0x0B) | Should -Be "2b" + } + + It "spills field ids of 6 or higher into a second header byte" { + script:Format-ByteHex (ConvertTo-BondFieldHeader -FieldId 10 -BondType 0x02) | Should -Be "c2 0a" + script:Format-ByteHex (ConvertTo-BondFieldHeader -FieldId 20 -BondType 0x0A) | Should -Be "ca 14" + script:Format-ByteHex (ConvertTo-BondFieldHeader -FieldId 40 -BondType 0x0F) | Should -Be "cf 28" + } + + It "round-trips field headers" -ForEach @( + @{ FieldId = 0; BondType = 2 } + @{ FieldId = 5; BondType = 14 } + @{ FieldId = 60; BondType = 10 } + @{ FieldId = 300; BondType = 16 } + ) { + $header = ConvertFrom-BondFieldHeader -Bytes (ConvertTo-BondFieldHeader -FieldId $FieldId -BondType $BondType) -Offset 0 + $header.FieldId | Should -Be $FieldId + $header.BondType | Should -Be $BondType + } +} + +Describe "CloudStore wrapper" -Tag "Unit" { + It "extracts the inner payload and timestamp" { + $wrapper = ConvertFrom-CloudStoreBlob -Blob $script:SampleSettingsBlob + $wrapper.Timestamp | Should -Be ([uint64]1786909007) + $wrapper.Payload.Length | Should -Be 0x23 + script:Format-ByteHex $wrapper.Payload[0..3] | Should -Be "43 42 01 00" + } + + It "round-trips a blob byte for byte" { + $wrapper = ConvertFrom-CloudStoreBlob -Blob $script:SampleSettingsBlob + script:Format-ByteHex (ConvertTo-CloudStoreBlob -Payload $wrapper.Payload -Timestamp $wrapper.Timestamp) | + Should -Be (script:Format-ByteHex $script:SampleSettingsBlob) + } + + It "stamps the current time when no timestamp is supplied" { + $before = [uint64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $blob = ConvertTo-CloudStoreBlob -Payload ([byte[]]@(0x43, 0x42, 0x01, 0x00, 0x00)) + (ConvertFrom-CloudStoreBlob -Blob $blob).Timestamp | Should -BeGreaterOrEqual $before + } + + It "rejects a blob without the marshaled CB v1 header" { + { ConvertFrom-CloudStoreBlob -Blob ([byte[]]::new(32)) } | Should -Throw "*marshaled CB v1 header*" + } + + It "rejects a truncated blob" { + { ConvertFrom-CloudStoreBlob -Blob ([byte[]]@(0x43, 0x42, 0x01, 0x00)) } | Should -Throw "*too short*" + } +} + +Describe "Night Light settings payload" -Tag "Unit" { + BeforeAll { + $script:SamplePayload = (ConvertFrom-CloudStoreBlob -Blob $script:SampleSettingsBlob).Payload + $script:SampleSettings = ConvertFrom-NightLightSettingsPayload -Payload $script:SamplePayload + } + + It "decodes the schedule mode as sunset to sunrise" { + $script:SampleSettings.ScheduleEnabled | Should -BeTrue + $script:SampleSettings.SetHoursMode | Should -BeFalse + } + + It "decodes the schedule, sunset and sunrise time blocks" { + $script:SampleSettings.StartHour | Should -Be 21 + $script:SampleSettings.StartMinute | Should -Be 0 + $script:SampleSettings.EndHour | Should -Be 7 + $script:SampleSettings.SunsetHour | Should -Be 21 + $script:SampleSettings.SunsetMinute | Should -Be 6 + $script:SampleSettings.SunriseHour | Should -Be 6 + $script:SampleSettings.SunriseMinute | Should -Be 25 + } + + It "decodes the colour temperature" { + $script:SampleSettings.ColorTemperature | Should -Be 3850 + } + + It "round-trips the payload byte for byte" { + script:Format-ByteHex (ConvertTo-NightLightSettingsPayload -Settings $script:SampleSettings) | + Should -Be (script:Format-ByteHex $script:SamplePayload) + } + + It "emits the set-hours presence flag only in set-hours mode" { + $settings = ConvertFrom-NightLightSettingsPayload -Payload $script:SamplePayload + $settings.SetHoursMode = $true + $payload = ConvertTo-NightLightSettingsPayload -Settings $settings + + # C2 0A = field 10, BT_BOOL. + $hex = ($payload | ForEach-Object { $_.ToString('x2') }) -join '' + $hex | Should -Match 'c20a' + (ConvertFrom-NightLightSettingsPayload -Payload $payload).SetHoursMode | Should -BeTrue + } + + It "omits time-block fields that hold the default value" { + script:Format-ByteHex (Write-NightLightTimeBlock -FieldId 30 -Hour 0 -Minute 0) | + Should -Be "ca 1e 00" + } + + It "round-trips arbitrary time blocks" -ForEach @( + @{ Hour = 0; Minute = 0 } + @{ Hour = 1; Minute = 15 } + @{ Hour = 23; Minute = 59 } + @{ Hour = 12; Minute = 0 } + ) { + $block = Write-NightLightTimeBlock -FieldId 20 -Hour $Hour -Minute $Minute + # Skip the two-byte field header to read the inner struct. + $decoded = Read-NightLightTimeBlock -Bytes $block -Offset 2 + $decoded.Hour | Should -Be $Hour + $decoded.Minute | Should -Be $Minute + } +} + +Describe "Night Light state payload" -Tag "Unit" { + It "decodes the enabled flag and last transition time" { + $state = ConvertFrom-NightLightStatePayload -Payload (ConvertFrom-CloudStoreBlob -Blob $script:SampleStateBlob).Payload + $state.Enabled | Should -BeTrue + $state.LastTransitionFileTime | Should -Be ([uint64]134313824772590959) + } + + It "round-trips the payload byte for byte" { + $payload = (ConvertFrom-CloudStoreBlob -Blob $script:SampleStateBlob).Payload + $state = ConvertFrom-NightLightStatePayload -Payload $payload + script:Format-ByteHex (ConvertTo-NightLightStatePayload -Enabled $state.Enabled -FileTime $state.LastTransitionFileTime) | + Should -Be (script:Format-ByteHex $payload) + } + + It "omits field 0 when Night Light is off" { + $payload = ConvertTo-NightLightStatePayload -Enabled $false -FileTime ([uint64]134313824772590959) + (ConvertFrom-NightLightStatePayload -Payload $payload).Enabled | Should -BeFalse + $payload.Length | Should -BeLessThan (ConvertTo-NightLightStatePayload -Enabled $true -FileTime ([uint64]134313824772590959)).Length + } +} + +Describe "Strength conversion" -Tag "Unit" { + It "maps strength to colour temperature" -ForEach @( + @{ Strength = 0; Kelvin = 6500 } + @{ Strength = 50; Kelvin = 3850 } + @{ Strength = 60; Kelvin = 3320 } + @{ Strength = 100; Kelvin = 1200 } + ) { + ConvertTo-NightLightColorTemperature -Strength $Strength | Should -Be $Kelvin + ConvertFrom-NightLightColorTemperature -Kelvin $Kelvin | Should -Be $Strength + } + + It "rejects a strength outside 0-100" { + { ConvertTo-NightLightColorTemperature -Strength 101 } | Should -Throw + } +} + +Describe "Night window detection" -Tag "Unit" { + BeforeAll { + $script:WindowSettings = [pscustomobject]@{ + SunsetHour = 21; SunsetMinute = 6; SunriseHour = 6; SunriseMinute = 25 + } + } + + It "treats times inside the sunset-to-sunrise window as night" -ForEach @( + @{ Time = "21:06" } + @{ Time = "23:59" } + @{ Time = "00:00" } + @{ Time = "06:24" } + ) { + Test-NightLightWithinNightWindow -Settings $script:WindowSettings -Now ([DateTime]::Parse($Time)) | + Should -BeTrue + } + + It "treats daytime as outside the window" -ForEach @( + @{ Time = "06:25" } + @{ Time = "12:00" } + @{ Time = "21:05" } + ) { + Test-NightLightWithinNightWindow -Settings $script:WindowSettings -Now ([DateTime]::Parse($Time)) | + Should -BeFalse + } + + It "returns false when sunset and sunrise are unknown" { + $settings = [pscustomobject]@{ SunsetHour = 0; SunsetMinute = 0; SunriseHour = 0; SunriseMinute = 0 } + Test-NightLightWithinNightWindow -Settings $settings -Now ([DateTime]::Parse("23:00")) | Should -BeFalse + } +} + +Describe "Set-NightLightConfiguration" -Tag "Unit" { + It "reports no change when the desired configuration is already applied" { + $fake = script:New-FakeNightLightRegistry + $results = Set-NightLightConfiguration -Strength 50 -Now ([DateTime]::Parse("22:00")) ` + -GetRegistryValue { param([string]$Path) $fake.Store[$Path] } ` + -SetRegistryValue { param([string]$Path, [byte[]]$Value) $fake.Store[$Path] = $Value; $fake.Writes.Add($Path) } + + @($results | Where-Object { $_.Changed }).Count | Should -Be 0 + $fake.Writes.Count | Should -Be 0 + } + + It "writes the requested strength while preserving sunset and sunrise times" { + $fake = script:New-FakeNightLightRegistry + Set-NightLightConfiguration -Strength 80 -Now ([DateTime]::Parse("22:00")) ` + -GetRegistryValue { param([string]$Path) $fake.Store[$Path] } ` + -SetRegistryValue { param([string]$Path, [byte[]]$Value) $fake.Store[$Path] = $Value; $fake.Writes.Add($Path) } | Out-Null + + $written = ConvertFrom-NightLightSettingsPayload -Payload (ConvertFrom-CloudStoreBlob -Blob $fake.Store[$script:NightLightSettingsPath]).Payload + $written.ColorTemperature | Should -Be 2260 + $written.ScheduleEnabled | Should -BeTrue + $written.SetHoursMode | Should -BeFalse + $written.SunsetHour | Should -Be 21 + $written.SunriseMinute | Should -Be 25 + } + + It "switches set-hours mode back to sunset to sunrise" { + $settings = ConvertFrom-NightLightSettingsPayload -Payload (ConvertFrom-CloudStoreBlob -Blob $script:SampleSettingsBlob).Payload + $settings.SetHoursMode = $true + $blob = ConvertTo-CloudStoreBlob -Payload (ConvertTo-NightLightSettingsPayload -Settings $settings) + + $fake = script:New-FakeNightLightRegistry -SettingsBlob $blob + Set-NightLightConfiguration -Strength 50 -Now ([DateTime]::Parse("22:00")) ` + -GetRegistryValue { param([string]$Path) $fake.Store[$Path] } ` + -SetRegistryValue { param([string]$Path, [byte[]]$Value) $fake.Store[$Path] = $Value; $fake.Writes.Add($Path) } | Out-Null + + $written = ConvertFrom-NightLightSettingsPayload -Payload (ConvertFrom-CloudStoreBlob -Blob $fake.Store[$script:NightLightSettingsPath]).Payload + $written.SetHoursMode | Should -BeFalse + } + + It "turns Night Light off outside the sunset-to-sunrise window" { + $fake = script:New-FakeNightLightRegistry + Set-NightLightConfiguration -Strength 50 -Now ([DateTime]::Parse("12:00")) ` + -GetRegistryValue { param([string]$Path) $fake.Store[$Path] } ` + -SetRegistryValue { param([string]$Path, [byte[]]$Value) $fake.Store[$Path] = $Value; $fake.Writes.Add($Path) } | Out-Null + + $state = ConvertFrom-NightLightStatePayload -Payload (ConvertFrom-CloudStoreBlob -Blob $fake.Store[$script:NightLightStatePath]).Payload + $state.Enabled | Should -BeFalse + } + + It "turns Night Light on inside the sunset-to-sunrise window" { + $fake = script:New-FakeNightLightRegistry -StateBlob (ConvertTo-CloudStoreBlob -Payload (ConvertTo-NightLightStatePayload -Enabled $false)) + Set-NightLightConfiguration -Strength 50 -Now ([DateTime]::Parse("23:30")) ` + -GetRegistryValue { param([string]$Path) $fake.Store[$Path] } ` + -SetRegistryValue { param([string]$Path, [byte[]]$Value) $fake.Store[$Path] = $Value; $fake.Writes.Add($Path) } | Out-Null + + $state = ConvertFrom-NightLightStatePayload -Payload (ConvertFrom-CloudStoreBlob -Blob $fake.Store[$script:NightLightStatePath]).Payload + $state.Enabled | Should -BeTrue + } + + It "does not write anything when -WhatIf is supplied" { + $fake = script:New-FakeNightLightRegistry + $results = Set-NightLightConfiguration -Strength 90 -Now ([DateTime]::Parse("22:00")) -WhatIf ` + -GetRegistryValue { param([string]$Path) $fake.Store[$Path] } ` + -SetRegistryValue { param([string]$Path, [byte[]]$Value) $fake.Store[$Path] = $Value; $fake.Writes.Add($Path) } + + $fake.Writes.Count | Should -Be 0 + @($results | Where-Object { $_.Status -eq "WhatIf" }).Count | Should -BeGreaterThan 0 + } + + It "fails with actionable guidance when Night Light was never initialised" { + $fake = script:New-FakeNightLightRegistry -SettingsBlob $null + { + Set-NightLightConfiguration -Strength 50 ` + -GetRegistryValue { param([string]$Path) $fake.Store[$Path] } ` + -SetRegistryValue { param([string]$Path, [byte[]]$Value) $fake.Store[$Path] = $Value } + } | Should -Throw "*not initialised*" + } +}