From 7b5af8d5586e3dca9523423c96b5c20170bedce8 Mon Sep 17 00:00:00 2001 From: Jean-Paul van Ravensberg <14926452+DevSecNinja@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:29:58 +0200 Subject: [PATCH 1/2] fix(windows): detect Windows PowerShell in the Night Light script The Night Light script guarded on $IsWindows, which only exists in PowerShell Core. Chezmoi runs .ps1 scripts with `powershell` (Windows PowerShell 5.1, PSEdition "Desktop") per the ps1 interpreter in .chezmoi.yaml.tmpl, where the variable is undefined. The negated guard was therefore always true and the script skipped itself on the exact platform it targets, printing "[SKIP] Night Light is a Windows-only setting." Replaces the guard with Test-WindowsHost, which treats PSEdition "Desktop" as Windows and otherwise falls back to $IsWindows. The rest of the script was already 5.1-compatible; verified the codec still round-trips the live registry blobs byte for byte under 5.1. Adds regression coverage: the script must not negate $IsWindows directly, and Test-WindowsHost plus a full script run are exercised in a real Windows PowerShell 5.1 child process. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../run_onchange_41-set-night-light.ps1 | 24 +++++++++++++++- tests/powershell/NightLight.Tests.ps1 | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) 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..7409d7ab 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,28 @@ 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 Set-NightLightConfiguration { <# .SYNOPSIS @@ -605,7 +627,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..b88ada0d 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 } From 8cfc5622cfdb541fee495a80a52aac2065d11f18 Mon Sep 17 00:00:00 2001 From: Jean-Paul van Ravensberg <14926452+DevSecNinja@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:47:19 +0200 Subject: [PATCH 2/2] fix(windows): seed Night Light settings when never initialised Fixing the $IsWindows guard made the script actually run on Windows CI runners, where it then failed the whole chezmoi apply: a fresh machine has never used Night Light, so the CloudStore values do not exist and the script threw "Night Light settings are not initialised". The script now seeds a baseline instead of throwing. Sunset and sunrise are deliberately left unset: Windows derives them from the machine location, and seeding them would risk storing wrong times. With no solar window known, the state value is left off rather than forced on. Verified on a live Windows 11 install by backing up both blobs, deleting the keys, and writing a from-scratch payload: Windows accepts and retains it. The original settings were restored afterwards. Renames the helper to Get-NightLightDefaultSetting to satisfy PSScriptAnalyzer (PSUseShouldProcessForStateChangingFunctions, PSUseSingularNouns) and to match the Get-WindowsPersonalizationSetting convention in the sibling script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/customization.md | 6 ++ .../run_onchange_41-set-night-light.ps1 | 42 ++++++++++++-- tests/powershell/NightLight.Tests.ps1 | 57 ++++++++++++++++++- 3 files changed, 98 insertions(+), 7 deletions(-) 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 7409d7ab..3079c114 100644 --- a/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 +++ b/home/.chezmoiscripts/windows/run_onchange_41-set-night-light.ps1 @@ -536,6 +536,34 @@ function Test-WindowsHost { 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 @@ -583,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 } diff --git a/tests/powershell/NightLight.Tests.ps1 b/tests/powershell/NightLight.Tests.ps1 index b88ada0d..19fe3a9b 100644 --- a/tests/powershell/NightLight.Tests.ps1 +++ b/tests/powershell/NightLight.Tests.ps1 @@ -295,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]@{ @@ -396,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 } }