diff --git a/docs/customization.md b/docs/customization.md index 8699aa60..e7e07004 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -325,6 +325,12 @@ 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. +On a machine where Night Light has never been used the two values do not exist +yet. Rather than failing the apply, the script seeds them. Sunset and sunrise +are deliberately left unset in that case: Windows derives them from the machine +location, and seeding them would only risk storing wrong times. Until Windows +computes them, the solar window is unknown and Night Light is left off. + Reversal: open Settings > System > Display > Night light and turn it off, or delete the two registry values above and sign out. diff --git a/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 b/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 index b88a6403..3079c114 100644 --- a/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 +++ b/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 @@ -514,6 +514,56 @@ function Test-NightLightWithinNightWindow { #endregion +function Test-WindowsHost { + <# + .SYNOPSIS + True when running on Windows, under either PowerShell edition. + + .DESCRIPTION + $IsWindows only exists in PowerShell Core. Chezmoi runs .ps1 scripts + with `powershell` (Windows PowerShell 5.1, PSEdition "Desktop"), + where the variable is undefined, so a guard that negates it directly + is always true and skips the script on the very platform it targets. + Desktop edition only ships on Windows, so treat it as a match. + #> + [OutputType([bool])] + param() + + if ($PSVersionTable.PSEdition -eq "Desktop") { + return $true + } + + return [bool](Get-Variable -Name IsWindows -ValueOnly -ErrorAction SilentlyContinue) +} + +function Get-NightLightDefaultSetting { + <# + .SYNOPSIS + Baseline settings for a machine where Night Light was never used. + + .DESCRIPTION + Sunset and sunrise are left at their defaults (00:00). Windows + recomputes them from the machine location and writes them back, so + seeding them here would only risk storing wrong times. + #> + [OutputType([psobject])] + param() + + return [pscustomobject]@{ + ScheduleEnabled = $true + SetHoursMode = $false + StartHour = 21 + StartMinute = 0 + EndHour = 7 + EndMinute = 0 + ColorTemperature = $script:NightLightMaxKelvin + SunsetHour = 0 + SunsetMinute = 0 + SunriseHour = 0 + SunriseMinute = 0 + } +} + function Set-NightLightConfiguration { <# .SYNOPSIS @@ -561,12 +611,18 @@ function Set-NightLightConfiguration { $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." + if ($settingsBlob) { + $settings = ConvertFrom-NightLightSettingsPayload -Payload (ConvertFrom-CloudStoreBlob -Blob ([byte[]]$settingsBlob)).Payload + } + else { + # Night Light has never been used on this machine (fresh install, or a + # host without the feature). Seed a baseline rather than failing the + # whole chezmoi apply. + Write-Verbose "Night Light settings not present; creating them from scratch." + $settings = Get-NightLightDefaultSetting } - $settings = ConvertFrom-NightLightSettingsPayload -Payload (ConvertFrom-CloudStoreBlob -Blob ([byte[]]$settingsBlob)).Payload - $settingsCorrect = $settings.ScheduleEnabled -and -not $settings.SetHoursMode -and $settings.ColorTemperature -eq $desiredKelvin + $settingsCorrect = $settingsBlob -and $settings.ScheduleEnabled -and -not $settings.SetHoursMode -and $settings.ColorTemperature -eq $desiredKelvin if ($settingsCorrect) { $results += [pscustomobject]@{ Setting = "Schedule and strength"; Status = "AlreadySet"; Changed = $false } @@ -605,7 +661,7 @@ function Set-NightLightConfiguration { } if (-not $SkipApply) { - if (-not $IsWindows) { + if (-not (Test-WindowsHost)) { Write-Host "[SKIP] Night Light is a Windows-only setting." -ForegroundColor Yellow return } diff --git a/tests/powershell/NightLight.Tests.ps1 b/tests/powershell/NightLight.Tests.ps1 index 5284b51f..19fe3a9b 100644 --- a/tests/powershell/NightLight.Tests.ps1 +++ b/tests/powershell/NightLight.Tests.ps1 @@ -85,6 +85,34 @@ Describe "Night Light script" -Tag "Unit" { } } +Describe "Windows host detection" -Tag "Unit" { + It "reports true on the current Windows host" { + Test-WindowsHost | Should -BeTrue + } + + It "does not guard on the bare `$IsWindows variable" { + # $IsWindows is undefined in Windows PowerShell 5.1, which is the + # interpreter chezmoi uses for .ps1 scripts. A bare guard therefore + # skips the script on the exact platform it targets. + $content = Get-Content -Path $script:ScriptPath -Raw + $content | Should -Not -Match '-not\s+\$IsWindows' + } + + It "returns true under Windows PowerShell 5.1" -Skip:(-not (Get-Command powershell.exe -ErrorAction SilentlyContinue)) { + $output = & powershell.exe -NoLogo -NoProfile -Command @" +. '$script:ScriptPath' -SkipApply +if (Test-WindowsHost) { 'True' } else { 'False' } +"@ + $LASTEXITCODE | Should -Be 0 + ($output | Select-Object -Last 1) | Should -Be "True" + } + + It "runs without skipping under Windows PowerShell 5.1" -Skip:(-not (Get-Command powershell.exe -ErrorAction SilentlyContinue)) { + $output = & powershell.exe -NoLogo -NoProfile -File $script:ScriptPath -WhatIf 2>&1 + ($output -join "`n") | Should -Not -Match 'Windows-only setting' + } +} + Describe "Bond CompactBinary primitives" -Tag "Unit" { It "round-trips varints" -ForEach @( @{ Value = [uint64]0 } @@ -267,6 +295,25 @@ Describe "Strength conversion" -Tag "Unit" { } } +Describe "Default settings" -Tag "Unit" { + It "describes a sunset-to-sunrise schedule with no cached solar times" { + $defaults = Get-NightLightDefaultSetting + $defaults.ScheduleEnabled | Should -BeTrue + $defaults.SetHoursMode | Should -BeFalse + $defaults.SunsetHour | Should -Be 0 + $defaults.SunriseHour | Should -Be 0 + } + + It "round-trips through the payload codec" { + $defaults = Get-NightLightDefaultSetting + $payload = ConvertTo-NightLightSettingsPayload -Settings $defaults + $decoded = ConvertFrom-NightLightSettingsPayload -Payload $payload + $decoded.StartHour | Should -Be $defaults.StartHour + $decoded.EndHour | Should -Be $defaults.EndHour + $decoded.ScheduleEnabled | Should -BeTrue + } +} + Describe "Night window detection" -Tag "Unit" { BeforeAll { $script:WindowSettings = [pscustomobject]@{ @@ -368,12 +415,44 @@ Describe "Set-NightLightConfiguration" -Tag "Unit" { @($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 + It "creates the settings from scratch when Night Light was never initialised" { + $fake = script:New-FakeNightLightRegistry -SettingsBlob $null -StateBlob $null + $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 -BeGreaterThan 0 + $fake.Store.ContainsKey($script:NightLightSettingsPath) | Should -BeTrue + + $written = ConvertFrom-NightLightSettingsPayload -Payload (ConvertFrom-CloudStoreBlob -Blob $fake.Store[$script:NightLightSettingsPath]).Payload + $written.ScheduleEnabled | Should -BeTrue + $written.SetHoursMode | Should -BeFalse + $written.ColorTemperature | Should -Be 3850 + } + + It "leaves sunset and sunrise unset when creating from scratch" { + # Windows computes these from the machine location; seeding them would + # only risk storing wrong times. + $fake = script:New-FakeNightLightRegistry -SettingsBlob $null -StateBlob $null + 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.SunsetHour | Should -Be 0 + $written.SunriseHour | Should -Be 0 + + # With no solar window known, Night Light must not be forced on. + $state = ConvertFrom-NightLightStatePayload -Payload (ConvertFrom-CloudStoreBlob -Blob $fake.Store[$script:NightLightStatePath]).Payload + $state.Enabled | Should -BeFalse + } + + It "does not throw on a host where Night Light was never initialised" { + $fake = script:New-FakeNightLightRegistry -SettingsBlob $null -StateBlob $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*" + } | Should -Not -Throw } }