diff --git a/docs/customization.md b/docs/customization.md index 1663c239..cd09f9c2 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -232,6 +232,38 @@ PATH when the folder exists, so it survives reboots and is available to GUI apps. The PowerShell profile also adds the same folder to the current session when OneDrive is configured. +## Windows personalization + +On Windows, `run_onchange_40-set-current-user-personalization.ps1` applies the +opinionated settings below to the invoking user's profile without elevation: + +| Preference | Applied value | Evidence/source | Normal alternative or reversal | +| ---------- | ------------- | --------------- | ------------------------------ | +| App and Windows theme | Dark (`AppsUseLightTheme=0`, `SystemUsesLightTheme=0`) | Grade 3: related [Microsoft theme guidance](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/apply-windows-themes), but the registry mappings are community-documented | Choose Light in Settings > Personalization > Colors, delete the values, or set both to `1` | +| Lock-screen Spotlight overlays | Off (`RotatingLockScreenOverlayEnabled=0`) | Grade 3: [Microsoft Community mapping](https://learn.microsoft.com/en-us/answers/questions/1326668/how-to-disable-windows-spotlight-via-registry) | Re-enable lock-screen tips, delete the value, or set it to `1` | +| Task View button | Hidden (`ShowTaskViewButton=0`) | Grade 1: [Windows 11 settings reference](https://learn.microsoft.com/en-us/windows/apps/develop/settings/settings-windows-11) | Enable Task view in taskbar settings, delete the value, or set it to `1` | +| Taskbar search | Hidden (`SearchboxTaskbarMode=0`) | Grade 2: modes correspond to the [Windows 11 settings reference](https://learn.microsoft.com/en-us/windows/apps/develop/settings/settings-windows-11), but this value is not a documented policy | Select Search box in taskbar settings, delete the value, or set it to `3` | +| File-name extensions | Shown (`HideFileExt=0`) | Grade 3: exact registry mapping is unverified | Clear File Explorer's File name extensions option, delete the value, or set it to `1` | +| Hidden files and folders | Shown (`Hidden=1`) | Grade 3: exact registry mapping is unverified | Clear File Explorer's Hidden items option, delete the value, or set it to `2` | +| Desktop background | Windows Spotlight (`BackgroundType=3`) | Grade 3: Spotlight is documented in the [settings reference](https://learn.microsoft.com/en-us/windows/apps/develop/settings/settings-windows-11), but the registry mirror is best-effort | Choose another background, delete the value, or set image mode (`1`) | +| Regional format | Dutch (Netherlands), `nl-NL` | Grade 1: [`Set-Culture`](https://learn.microsoft.com/en-us/powershell/module/international/set-culture) | Select another regional format or run `Set-Culture -CultureInfo en-US` | +| Home location | Netherlands, GeoID `176` | Grade 1: [`Set-WinHomeLocation`](https://learn.microsoft.com/en-us/powershell/module/international/set-winhomelocation) | Select another country or run `Set-WinHomeLocation -GeoId 244` for the US | +| Languages and keyboards | `en-US`, then `nl-NL`; both US-International (`00020409`) | Grade 1: [`Set-WinUserLanguageList`](https://learn.microsoft.com/en-us/powershell/module/international/set-winuserlanguagelist) | Edit Language & region settings or run `Set-WinUserLanguageList en-US -Force` | +| Number and CSV separators | Decimal `.`, thousands `,`, list `,` | Grade 2: [Windows locale constants](https://learn.microsoft.com/en-us/windows/win32/intl/locale-custom-constants) document the fields; registry behavior is vendor-observed | Restore Dutch defaults: decimal `,`, thousands `.`, list `;` | + +The script checks exact desired state before every change and preserves +unrelated registry values. It intentionally replaces the input-language list +with the two entries above. Number separators are applied after `Set-Culture`, +which rewrites `HKCU\Control Panel\International` and otherwise restores the +Dutch defaults. The decimal symbol is restored first because Windows will not +allow the decimal and list separators to match. All registry writes are limited +to `HKEY_CURRENT_USER`. + +The executable settings table in the script carries the same descriptions, +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. + ## Learn More - [Chezmoi documentation](https://www.chezmoi.io/user-guide/command-overview/) diff --git a/home/.chezmoiscripts/windows/run_onchange_40-set-current-user-personalization.ps1 b/home/.chezmoiscripts/windows/run_onchange_40-set-current-user-personalization.ps1 new file mode 100644 index 00000000..86bd2ee9 --- /dev/null +++ b/home/.chezmoiscripts/windows/run_onchange_40-set-current-user-personalization.ps1 @@ -0,0 +1,352 @@ +#!/usr/bin/env pwsh +# Apply opinionated Windows personalization to the invoking user's profile. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "PSAvoidUsingWriteHost", + "", + Justification = "Matches existing chezmoi setup script progress output." +)] +[CmdletBinding(SupportsShouldProcess)] +param( + [switch]$SkipApply +) + +$ErrorActionPreference = "Stop" + +function Get-WindowsPersonalizationSetting { + $settings = @( + [pscustomobject]@{ + Id = "dark-mode-apps"; Setting = "Dark mode for apps"; SettingType = "Registry"; Phase = "BeforeCulture" + Path = "Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; Name = "AppsUseLightTheme" + Value = 0; Kind = "DWord"; Command = $null + Description = "Selects the dark theme for applications." + Rationale = "Keeps application surfaces in the preferred dark theme. The registry mapping is community-documented, not an official policy contract." + Citation = "https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/apply-windows-themes" + EvidenceGrade = 3 + Reversal = "Set AppsUseLightTheme to 1, delete the value, or choose Light in Settings > Personalization > Colors." + } + [pscustomobject]@{ + Id = "dark-mode-system"; Setting = "Dark mode for Windows"; SettingType = "Registry"; Phase = "BeforeCulture" + Path = "Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; Name = "SystemUsesLightTheme" + Value = 0; Kind = "DWord"; Command = $null + Description = "Selects the dark theme for the taskbar, Start, and other Windows surfaces." + Rationale = "Keeps system surfaces in the preferred dark theme. The registry mapping is community-documented, not an official policy contract." + Citation = "https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/apply-windows-themes" + EvidenceGrade = 3 + Reversal = "Set SystemUsesLightTheme to 1, delete the value, or choose Light in Settings > Personalization > Colors." + } + [pscustomobject]@{ + Id = "disable-lockscreen-spotlight-overlay"; Setting = "Lock-screen Spotlight overlays"; SettingType = "Registry"; Phase = "BeforeCulture" + Path = "Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager"; Name = "RotatingLockScreenOverlayEnabled" + Value = 0; Kind = "DWord"; Command = $null + Description = "Disables Windows Spotlight lock-screen fun-fact, suggestion, and advertising overlays." + Rationale = "Reduces promotional content on the lock screen. This per-user mapping is documented by Microsoft Community rather than an official registry policy." + Citation = "https://learn.microsoft.com/en-us/answers/questions/1326668/how-to-disable-windows-spotlight-via-registry" + EvidenceGrade = 3 + Reversal = "Set RotatingLockScreenOverlayEnabled to 1, delete the value, or re-enable lock-screen tips in Settings." + } + [pscustomobject]@{ + Id = "hide-task-view"; Setting = "Task View button"; SettingType = "Registry"; Phase = "BeforeCulture" + Path = "Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced"; Name = "ShowTaskViewButton" + Value = 0; Kind = "DWord"; Command = $null + Description = "Hides the Task View button while leaving the Win+Tab shortcut available." + Rationale = "Removes an unused taskbar control without disabling Task View." + Citation = "https://learn.microsoft.com/en-us/windows/apps/develop/settings/settings-windows-11" + EvidenceGrade = 1 + Reversal = "Set ShowTaskViewButton to 1, delete the value, or enable Task view in Settings > Personalization > Taskbar." + } + [pscustomobject]@{ + Id = "hide-taskbar-search"; Setting = "Taskbar search"; SettingType = "Registry"; Phase = "BeforeCulture" + Path = "Software\Microsoft\Windows\CurrentVersion\Search"; Name = "SearchboxTaskbarMode" + Value = 0; Kind = "DWord"; Command = $null + Description = "Hides the taskbar search box and icon." + Rationale = "Removes an unused taskbar control; Windows Search remains available from Start. The value mirrors documented modes but is not itself a documented policy." + Citation = "https://learn.microsoft.com/en-us/windows/apps/develop/settings/settings-windows-11" + EvidenceGrade = 2 + Reversal = "Set SearchboxTaskbarMode to 3, delete the value, or select Search box in Settings > Personalization > Taskbar." + } + [pscustomobject]@{ + Id = "show-file-extensions"; Setting = "File-name extensions"; SettingType = "Registry"; Phase = "BeforeCulture" + Path = "Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced"; Name = "HideFileExt" + Value = 0; Kind = "DWord"; Command = $null + Description = "Shows known file-type extensions in File Explorer." + Rationale = "Improves clarity and helps expose misleading names such as invoice.pdf.exe. The exact registry mapping has no retained authoritative citation." + Citation = "Unverified" + EvidenceGrade = 3 + Reversal = "Set HideFileExt to 1, delete the value, or clear View > Show > File name extensions in File Explorer." + } + [pscustomobject]@{ + Id = "show-hidden-items"; Setting = "Hidden files and folders"; SettingType = "Registry"; Phase = "BeforeCulture" + Path = "Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced"; Name = "Hidden" + Value = 1; Kind = "DWord"; Command = $null + Description = "Shows hidden files and folders in File Explorer." + Rationale = "Makes configuration and development files directly accessible. The exact registry mapping has no retained authoritative citation." + Citation = "Unverified" + EvidenceGrade = 3 + Reversal = "Set Hidden to 2, delete the value, or clear View > Show > Hidden items in File Explorer." + } + [pscustomobject]@{ + Id = "spotlight-desktop-background"; Setting = "Desktop background"; SettingType = "Registry"; Phase = "BeforeCulture" + Path = "Software\Microsoft\Windows\CurrentVersion\Explorer\Wallpapers"; Name = "BackgroundType" + Value = 3; Kind = "DWord"; Command = $null + Description = "Selects Windows Spotlight as the desktop background." + Rationale = "Uses the rotating Spotlight image. Microsoft documents Spotlight as wallpaper kind 3, but this per-user registry mirror is best-effort and community-grade." + Citation = "https://learn.microsoft.com/en-us/windows/apps/develop/settings/settings-windows-11" + EvidenceGrade = 3 + Reversal = "Set BackgroundType to 1, delete the value, or choose another background in Settings > Personalization > Background." + } + [pscustomobject]@{ + Id = "culture-nl"; Setting = "Regional format"; SettingType = "Cmdlet"; Phase = "Culture" + Path = $null; Name = "Culture"; Value = "nl-NL"; Kind = $null; Command = "Set-Culture" + Description = "Sets the current user's regional format to Dutch (Netherlands), including 24-hour time and day-month-year dates." + Rationale = "Uses the supported per-user cmdlet without changing the English display language. Set-Culture rewrites regional overrides, so number separators must run afterward." + Citation = "https://learn.microsoft.com/en-us/powershell/module/international/set-culture" + EvidenceGrade = 1 + Reversal = "Run Set-Culture -CultureInfo en-US or select another Regional format in Settings > Time & language > Language & region." + } + [pscustomobject]@{ + Id = "home-location-nl"; Setting = "Home location"; SettingType = "Cmdlet"; Phase = "Culture" + Path = $null; Name = "HomeLocation"; Value = 176; Kind = $null; Command = "Set-WinHomeLocation" + Description = "Sets the current user's home location GeoID to 176 (Netherlands)." + Rationale = "Aligns Region > Country or region with the Dutch regional format through the supported per-user cmdlet." + Citation = "https://learn.microsoft.com/en-us/powershell/module/international/set-winhomelocation" + EvidenceGrade = 1 + Reversal = "Run Set-WinHomeLocation -GeoId 244 for the United States or choose another Country or region in Settings." + } + [pscustomobject]@{ + Id = "language-list-us-international"; Setting = "Languages and keyboards"; SettingType = "Cmdlet"; Phase = "Culture" + Path = $null; Name = "LanguageList" + Value = "en-US:0409:00020409; nl-NL:0413:00020409"; Kind = $null; Command = "Set-WinUserLanguageList" + Description = "Sets exactly en-US then nl-NL, both using the United States-International keyboard layout." + Rationale = "Keeps English first while removing the unwanted plain-US layout. The supported cmdlet avoids hand-editing the serialized per-user language profile." + Citation = "https://learn.microsoft.com/en-us/powershell/module/international/set-winuserlanguagelist" + EvidenceGrade = 1 + Reversal = "Use Settings > Time & language > Language & region, or run Set-WinUserLanguageList en-US -Force, to restore a normal single-language list." + } + [pscustomobject]@{ + Id = "number-format-decimal-us"; Setting = "Decimal separator"; SettingType = "Registry"; Phase = "AfterCulture" + Path = "Control Panel\International"; Name = "sDecimal"; Value = "."; Kind = "String"; Command = $null + Description = "Uses a dot as the decimal symbol." + Rationale = "A comma CSV delimiter requires the decimal symbol to differ from the list separator. Applied after Set-Culture because that cmdlet restores nl-NL defaults." + Citation = "https://learn.microsoft.com/en-us/windows/win32/intl/locale-custom-constants" + EvidenceGrade = 2 + Reversal = "Set sDecimal to ',' to restore the nl-NL default, or choose another Regional format." + } + [pscustomobject]@{ + Id = "number-format-thousands-us"; Setting = "Thousands separator"; SettingType = "Registry"; Phase = "AfterCulture" + Path = "Control Panel\International"; Name = "sThousand"; Value = ","; Kind = "String"; Command = $null + Description = "Uses a comma as the digit-grouping symbol." + Rationale = "Completes the preferred US-style number format after the decimal symbol becomes a dot. Applied after Set-Culture because that cmdlet restores nl-NL defaults." + Citation = "https://learn.microsoft.com/en-us/windows/win32/intl/locale-custom-constants" + EvidenceGrade = 2 + Reversal = "Set sThousand to '.' to restore the nl-NL default, or choose another Regional format." + } + [pscustomobject]@{ + Id = "number-format-list-us"; Setting = "List separator"; SettingType = "Registry"; Phase = "AfterCulture" + Path = "Control Panel\International"; Name = "sList"; Value = ","; Kind = "String"; Command = $null + Description = "Uses a comma as the list separator for comma-delimited CSV files." + Rationale = "Excel uses the user list separator for CSV columns. Applied after Set-Culture, and after sDecimal, because Windows does not allow list and decimal separators to match." + Citation = "https://learn.microsoft.com/en-us/windows/win32/intl/locale-custom-constants" + EvidenceGrade = 2 + Reversal = "Set sList to ';' to restore the nl-NL default, or choose another Regional format." + } + ) + + return $settings +} + +function Get-WindowsPersonalizationRegistrySetting { + return @(Get-WindowsPersonalizationSetting | Where-Object { + $_.SettingType -eq "Registry" -and $_.Phase -eq "BeforeCulture" + }) +} + +function Get-WindowsNumberFormatRegistrySetting { + return @(Get-WindowsPersonalizationSetting | Where-Object { + $_.SettingType -eq "Registry" -and $_.Phase -eq "AfterCulture" + }) +} + +function Test-WindowsLanguageListDesired { + param( + [AllowNull()] + [object[]]$LanguageList + ) + + $languages = @($LanguageList) + if ($languages.Count -ne 2) { + return $false + } + + $expected = @( + @{ LanguageTag = "en-US"; InputMethodTip = "0409:00020409" } + @{ LanguageTag = "nl-NL"; InputMethodTip = "0413:00020409" } + ) + + for ($index = 0; $index -lt $expected.Count; $index++) { + $inputMethodTips = @($languages[$index].InputMethodTips) + if ($languages[$index].LanguageTag -ne $expected[$index].LanguageTag -or + $inputMethodTips.Count -ne 1 -or + $inputMethodTips[0] -ne $expected[$index].InputMethodTip) { + return $false + } + } + + return $true +} + +function Set-WindowsCurrentUserPersonalization { + [CmdletBinding(SupportsShouldProcess)] + param( + [scriptblock]$GetRegistryValue = { + param([string]$Path, [string]$Name) + + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($Path, $false) + try { + if (-not $key -or $Name -notin $key.GetValueNames()) { + return [pscustomobject]@{ Exists = $false; Value = $null; Kind = $null } + } + + return [pscustomobject]@{ + Exists = $true + Value = $key.GetValue($Name, $null) + Kind = $key.GetValueKind($Name).ToString() + } + } + finally { + if ($key) { + $key.Dispose() + } + } + }, + + [scriptblock]$SetRegistryValue = { + param([string]$Path, [string]$Name, [object]$Value, [string]$Kind) + + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($Path) + try { + if (-not $key) { + throw "Unable to open or create HKCU\$Path." + } + + $registryKind = [Microsoft.Win32.RegistryValueKind]::$Kind + $key.SetValue($Name, $Value, $registryKind) + } + finally { + if ($key) { + $key.Dispose() + } + } + }, + + [scriptblock]$GetCultureName = { + (Get-Culture -ErrorAction Stop).Name + }, + + [scriptblock]$SetCulture = { + Set-Culture -CultureInfo "nl-NL" -ErrorAction Stop + }, + + [scriptblock]$GetHomeGeoId = { + (Get-WinHomeLocation -ErrorAction Stop).GeoId + }, + + [scriptblock]$SetHomeLocation = { + Set-WinHomeLocation -GeoId 176 -ErrorAction Stop + }, + + [scriptblock]$GetLanguageList = { + @(Get-WinUserLanguageList -ErrorAction Stop) + }, + + [scriptblock]$SetLanguageList = { + $languages = New-WinUserLanguageList -Language "en-US" + $languages[0].InputMethodTips.Clear() + $languages[0].InputMethodTips.Add("0409:00020409") + $languages.Add("nl-NL") + $languages[1].InputMethodTips.Clear() + $languages[1].InputMethodTips.Add("0413:00020409") + Set-WinUserLanguageList -LanguageList $languages -Force -ErrorAction Stop + } + ) + + $results = @() + + foreach ($setting in Get-WindowsPersonalizationRegistrySetting) { + $current = & $GetRegistryValue -Path $setting.Path -Name $setting.Name + if ($current.Exists -and $current.Value -eq $setting.Value -and $current.Kind -eq $setting.Kind) { + $results += [pscustomobject]@{ Setting = $setting.Name; Status = "AlreadySet"; Changed = $false } + continue + } + + if ($PSCmdlet.ShouldProcess("HKCU\$($setting.Path)\$($setting.Name)", "Set value to '$($setting.Value)'")) { + & $SetRegistryValue -Path $setting.Path -Name $setting.Name -Value $setting.Value -Kind $setting.Kind + $results += [pscustomobject]@{ Setting = $setting.Name; Status = "Updated"; Changed = $true } + } + else { + $results += [pscustomobject]@{ Setting = $setting.Name; Status = "WhatIf"; Changed = $false } + } + } + + $cultureName = & $GetCultureName + if ($cultureName -eq "nl-NL") { + $results += [pscustomobject]@{ Setting = "Culture"; Status = "AlreadySet"; Changed = $false } + } + elseif ($PSCmdlet.ShouldProcess("Current user culture", "Set culture to nl-NL")) { + & $SetCulture + $results += [pscustomobject]@{ Setting = "Culture"; Status = "Updated"; Changed = $true } + } + else { + $results += [pscustomobject]@{ Setting = "Culture"; Status = "WhatIf"; Changed = $false } + } + + $homeGeoId = & $GetHomeGeoId + if ($homeGeoId -eq 176) { + $results += [pscustomobject]@{ Setting = "HomeLocation"; Status = "AlreadySet"; Changed = $false } + } + elseif ($PSCmdlet.ShouldProcess("Current user home location", "Set GeoId to 176 (Netherlands)")) { + & $SetHomeLocation + $results += [pscustomobject]@{ Setting = "HomeLocation"; Status = "Updated"; Changed = $true } + } + else { + $results += [pscustomobject]@{ Setting = "HomeLocation"; Status = "WhatIf"; Changed = $false } + } + + $languageList = @(& $GetLanguageList) + if (Test-WindowsLanguageListDesired -LanguageList $languageList) { + $results += [pscustomobject]@{ Setting = "LanguageList"; Status = "AlreadySet"; Changed = $false } + } + elseif ($PSCmdlet.ShouldProcess("Current user language list", "Set en-US and nl-NL to United States-International")) { + & $SetLanguageList + $results += [pscustomobject]@{ Setting = "LanguageList"; Status = "Updated"; Changed = $true } + } + else { + $results += [pscustomobject]@{ Setting = "LanguageList"; Status = "WhatIf"; Changed = $false } + } + + # Set-Culture replaces these overrides, so separators must always be checked last. + foreach ($setting in Get-WindowsNumberFormatRegistrySetting) { + $current = & $GetRegistryValue -Path $setting.Path -Name $setting.Name + if ($current.Exists -and $current.Value -eq $setting.Value -and $current.Kind -eq $setting.Kind) { + $results += [pscustomobject]@{ Setting = $setting.Name; Status = "AlreadySet"; Changed = $false } + continue + } + + if ($PSCmdlet.ShouldProcess("HKCU\$($setting.Path)\$($setting.Name)", "Set value to '$($setting.Value)'")) { + & $SetRegistryValue -Path $setting.Path -Name $setting.Name -Value $setting.Value -Kind $setting.Kind + $results += [pscustomobject]@{ Setting = $setting.Name; Status = "Updated"; Changed = $true } + } + else { + $results += [pscustomobject]@{ Setting = $setting.Name; Status = "WhatIf"; Changed = $false } + } + } + + return $results +} + +if (-not $SkipApply) { + Write-Host "Applying current-user Windows personalization..." -ForegroundColor Cyan + $results = @(Set-WindowsCurrentUserPersonalization -WhatIf:$WhatIfPreference) + $updatedCount = @($results | Where-Object { $_.Changed }).Count + Write-Host "[OK] Windows personalization complete ($updatedCount setting(s) changed)." -ForegroundColor Green +} diff --git a/tests/powershell/WindowsPersonalization.Tests.ps1 b/tests/powershell/WindowsPersonalization.Tests.ps1 new file mode 100644 index 00000000..c414c348 --- /dev/null +++ b/tests/powershell/WindowsPersonalization.Tests.ps1 @@ -0,0 +1,236 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Tests current-user Windows personalization. +#> + +BeforeAll { + $script:RepoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $script:ScriptPath = Join-Path $script:RepoRoot ` + "home\.chezmoiscripts\windows\run_onchange_40-set-current-user-personalization.ps1" + + . $script:ScriptPath -SkipApply + + function New-TestLanguageList { + param( + [string]$FirstTip = "0409:00020409", + [string]$SecondTip = "0413:00020409" + ) + + return @( + [pscustomobject]@{ LanguageTag = "en-US"; InputMethodTips = @($FirstTip) } + [pscustomobject]@{ LanguageTag = "nl-NL"; InputMethodTips = @($SecondTip) } + ) + } + + function Invoke-TestPersonalization { + param( + [hashtable]$Registry, + [string]$Culture = "en-US", + [int]$GeoId = 244, + [object[]]$Languages = @([pscustomobject]@{ + LanguageTag = "en-US" + InputMethodTips = @("0409:00000409") + }), + [switch]$WhatIf + ) + + $script:Events = @() + $script:RegistryWrites = @() + $script:CultureSetCount = 0 + $script:HomeSetCount = 0 + $script:LanguageSetCount = 0 + + Set-WindowsCurrentUserPersonalization ` + -GetRegistryValue { + param($Path, $Name) + $key = "$Path|$Name" + if ($Registry.ContainsKey($key)) { + return $Registry[$key] + } + [pscustomobject]@{ Exists = $false; Value = $null; Kind = $null } + } ` + -SetRegistryValue { + param($Path, $Name, $Value, $Kind) + $script:Events += "Registry:$Name" + $script:RegistryWrites += [pscustomobject]@{ + Path = $Path; Name = $Name; Value = $Value; Kind = $Kind + } + } ` + -GetCultureName { $Culture } ` + -SetCulture { + $script:Events += "Culture" + $script:CultureSetCount++ + } ` + -GetHomeGeoId { $GeoId } ` + -SetHomeLocation { + $script:Events += "HomeLocation" + $script:HomeSetCount++ + } ` + -GetLanguageList { $Languages } ` + -SetLanguageList { + $script:Events += "LanguageList" + $script:LanguageSetCount++ + } ` + -WhatIf:$WhatIf + } +} + +Describe "Current-user Windows personalization script" -Tag "Unit" { + It "exists as a native run_onchange PowerShell script with valid syntax" { + $script:ScriptPath | Should -Exist + $script:ScriptPath | Should -Match "run_onchange_" + $script:ScriptPath | Should -Not -Match "\.tmpl$" + + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $script:ScriptPath, + [ref]$null, + [ref]$errors + ) | Out-Null + $errors | Should -BeNullOrEmpty + } + + It "declares every migrated registry value with its exact desired state" { + $settings = @( + Get-WindowsPersonalizationRegistrySetting + Get-WindowsNumberFormatRegistrySetting + ) + $actual = @{} + foreach ($setting in $settings) { + $actual[$setting.Name] = "$($setting.Value)|$($setting.Kind)" + } + + $actual.AppsUseLightTheme | Should -Be "0|DWord" + $actual.SystemUsesLightTheme | Should -Be "0|DWord" + $actual.RotatingLockScreenOverlayEnabled | Should -Be "0|DWord" + $actual.ShowTaskViewButton | Should -Be "0|DWord" + $actual.SearchboxTaskbarMode | Should -Be "0|DWord" + $actual.HideFileExt | Should -Be "0|DWord" + $actual.Hidden | Should -Be "1|DWord" + $actual.BackgroundType | Should -Be "3|DWord" + $actual.sDecimal | Should -Be ".|String" + $actual.sThousand | Should -Be ",|String" + $actual.sList | Should -Be ",|String" + } + + It "keeps audit metadata attached to every applied registry setting" { + $allSettings = @(Get-WindowsPersonalizationSetting) + $registrySettings = @($allSettings | Where-Object SettingType -eq "Registry") + $appliedRegistrySettings = @( + Get-WindowsPersonalizationRegistrySetting + Get-WindowsNumberFormatRegistrySetting + ) + + $registrySettings.Count | Should -Be 11 + $appliedRegistrySettings.Count | Should -Be $registrySettings.Count + foreach ($setting in $appliedRegistrySettings) { + $setting.Setting | Should -Not -BeNullOrEmpty + $setting.Path | Should -Not -BeNullOrEmpty + $setting.Name | Should -Not -BeNullOrEmpty + $setting.Kind | Should -Not -BeNullOrEmpty + $setting.Description | Should -Not -BeNullOrEmpty + $setting.Rationale | Should -Not -BeNullOrEmpty + $setting.Citation | Should -Not -BeNullOrEmpty + $setting.EvidenceGrade | Should -BeIn @(1, 2, 3) + $setting.Reversal | Should -Not -BeNullOrEmpty + } + } + + It "documents cmdlet settings and the post-culture ordering dependency" { + $settings = @(Get-WindowsPersonalizationSetting) + $cmdletSettings = @($settings | Where-Object SettingType -eq "Cmdlet") + $afterCulture = @($settings | Where-Object Phase -eq "AfterCulture") + + $cmdletSettings.Count | Should -Be 3 + foreach ($setting in $cmdletSettings) { + $setting.Command | Should -Not -BeNullOrEmpty + $setting.Description | Should -Not -BeNullOrEmpty + $setting.Rationale | Should -Not -BeNullOrEmpty + $setting.Citation | Should -Match "^https://learn\.microsoft\.com/" + $setting.Reversal | Should -Not -BeNullOrEmpty + } + $afterCulture.Count | Should -Be 3 + foreach ($setting in $afterCulture) { + $setting.Rationale | Should -Match "after Set-Culture" + } + } + + It "accepts only the exact ordered language and keyboard list" { + Test-WindowsLanguageListDesired -LanguageList (New-TestLanguageList) | + Should -BeTrue + Test-WindowsLanguageListDesired -LanguageList @( + [pscustomobject]@{ LanguageTag = "nl-NL"; InputMethodTips = @("0413:00020409") } + [pscustomobject]@{ LanguageTag = "en-US"; InputMethodTips = @("0409:00020409") } + ) | Should -BeFalse + Test-WindowsLanguageListDesired -LanguageList ( + New-TestLanguageList -FirstTip "0409:00000409" + ) | Should -BeFalse + } + + It "updates drifted settings with locale before the number separators" { + $results = @(Invoke-TestPersonalization -Registry @{}) + + @($results | Where-Object Changed).Count | Should -Be 14 + $script:CultureSetCount | Should -Be 1 + $script:HomeSetCount | Should -Be 1 + $script:LanguageSetCount | Should -Be 1 + $script:Events.IndexOf("Culture") | + Should -BeLessThan $script:Events.IndexOf("Registry:sDecimal") + $script:Events.IndexOf("Registry:sDecimal") | + Should -BeLessThan $script:Events.IndexOf("Registry:sThousand") + $script:Events.IndexOf("Registry:sThousand") | + Should -BeLessThan $script:Events.IndexOf("Registry:sList") + } + + It "is idempotent when every setting already has the desired value and kind" { + $registry = @{} + foreach ($setting in @( + Get-WindowsPersonalizationRegistrySetting + Get-WindowsNumberFormatRegistrySetting + )) { + $registry["$($setting.Path)|$($setting.Name)"] = [pscustomobject]@{ + Exists = $true + Value = $setting.Value + Kind = $setting.Kind + } + } + + $results = @(Invoke-TestPersonalization ` + -Registry $registry ` + -Culture "nl-NL" ` + -GeoId 176 ` + -Languages (New-TestLanguageList)) + + @($results).Count | Should -Be 14 + @($results | Where-Object { $_.Status -ne "AlreadySet" }) | + Should -BeNullOrEmpty + $script:RegistryWrites | Should -BeNullOrEmpty + $script:CultureSetCount | Should -Be 0 + $script:HomeSetCount | Should -Be 0 + $script:LanguageSetCount | Should -Be 0 + } + + It "does not write any setting under WhatIf" { + $results = @(Invoke-TestPersonalization -Registry @{} -WhatIf) + + @($results).Count | Should -Be 14 + @($results | Where-Object { $_.Status -ne "WhatIf" }) | + Should -BeNullOrEmpty + $script:RegistryWrites | Should -BeNullOrEmpty + $script:CultureSetCount | Should -Be 0 + $script:HomeSetCount | Should -Be 0 + $script:LanguageSetCount | Should -Be 0 + } + + It "uses only current-user registry and supported per-user cmdlets" { + $content = Get-Content -LiteralPath $script:ScriptPath -Raw + + $content | Should -Match "Registry\]::CurrentUser" + $content | Should -Match "Set-Culture" + $content | Should -Match "Set-WinHomeLocation" + $content | Should -Match "Set-WinUserLanguageList" + $content | Should -Not -Match "HKLM|LocalMachine|RunOnce|ScheduledTask|Start-Process.+RunAs" + $content | Should -Not -Match "WindowsPrincipal|Administrator|#Requires -RunAsAdministrator" + } +}