WinPrefs.psm1
function ReplaceFullHiveNameWithShortName { param( [Parameter(Mandatory,HelpMessage = "Registry path with long name and no drive syntax.")] [string]$Path) $_unused,$HkeyParts = $Path.ToUpper().Split('\')[0].Split('_') $Path -replace '^HKEY_[^\\]+\\',"HK$($(foreach ($Item in $HkeyParts) { $Item[0] }) -Join ''):" } function GetFullHiveName { param( [Parameter(Mandatory,HelpMessage = "Registry path.")] [ValidatePattern('^HK(LM|CU|CR|U|CC|PD):')] [string]$Path ) switch -Wildcard ($Path) { 'HKCC:*' { 'HKEY_CURRENT_CONFIG' } 'HKCR:*' { 'HKEY_CLASSES_ROOT' } 'HKCU:*' { 'HKEY_CURRENT_USER' } 'HKLM:*' { 'HKEY_LOCAL_MACHINE' } 'HKU:*' { 'HKEY_USERS' } default { throw } } } function GetRegType () { param( [Parameter(Mandatory)] [AllowNull()] [ValidatePattern('^(Binary|(D|Q)Word|(Multi|Expand)String|String|None)')] $Value ) if ($null -eq $Value) { return 'REG_NONE' } switch ($Value) { 'Binary' { 'REG_BINARY' } 'DWord' { 'REG_DWORD' } 'ExpandString' { 'REG_EXPAND_SZ' } 'MultiString' { 'REG_MULTI_SZ' } 'None' { 'REG_NONE' } 'QWord' { 'REG_QWORD' } 'String' { 'REG_SZ' } default { throw "$Value" } } } function FixVParameter { param( [Parameter(Mandatory)] [string]$Prop ) if ($Prop -eq '(default)') { '/ve ' } else { "/v ""$(Escape $Prop)"" " } } function Escape { param( [Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] [string]$Value ) if ($null -eq $Value) { return "" } $Value -replace '"','""' -replace '%','%%' } function ConvertValueForReg { param( [Parameter(Mandatory)] [ValidatePattern('^REG_(BINARY|(?:Q|D)WORD|(?:(?:EXPAND|MULTI)_)?SZ|NONE)')] [string]$RegType, [Parameter(Mandatory)] [AllowNull()] $Value ) if ($null -eq $RegType) { return " " } switch -Regex ($RegType) { '^REG_BINARY$' { " /d $($(for ($i = 0; $i -lt $Value.Length; $i++) { "{0:x2}" -f $i}) -Join '') " } '^REG_MULTI_SZ$' { " /d ""$(Escape $($Value -Join "\0"))"" " } '^REG_(?:EXPAND_)?SZ$' { " /d ""$(Escape $Value)"" " } '^REG_(?:Q|D)WORD$' { " /d $Value " } '^REG_NONE$' { " " } default { throw "$RegType" } } } function DoWriteRegCommand { param( [Parameter(Mandatory)] [Microsoft.Win32.RegistryKey]$RegKeyObj, [Parameter(Mandatory)] [string]$Prop, [Parameter(Mandatory)] [string]$RegKey ) $GetValuePropArg = if ($Prop -eq '(default)') { $null } else { $Prop } $Value = $RegKeyObj.GetValue($GetValuePropArg,$null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) $RegProp = FixVParameter $Prop try { $ValueKind = $RegKeyObj.GetValueKind($GetValuePropArg).ToString() } catch { Write-Debug "Skipping $RegKey\$Prop. GetValueKind() failed." return } if ($ValueKind -eq 'Unknown') { Write-Debug "Skipping $RegKey\$Prop of unknown type." return } try { $RegType = GetRegType $ValueKind } catch { Write-Debug "Unable to determine registry type: RegKeyObj = $RegKeyObj, Prop = $Prop" return } if ($Value -match "(`r)?`n") { Write-Debug "Skipping $RegKeyObj $Prop because it contains newlines." return } $RegValue = ConvertValueForReg -RegType $RegType -Value $Value "reg add ""$(Escape $RegKey)"" $RegProp/t $RegType$RegValue/f" } function DoWriteRegCommands { param( [Parameter(Mandatory,HelpMessage = "Registry path.")] [ValidatePattern('^HK(LM|CU|CR|U|CC):')] [string]$Path ) $Hive = switch -Wildcard ($Path) { 'HKCC:*' { [Microsoft.Win32.Registry]::CurrentConfig } 'HKCR:*' { [Microsoft.Win32.Registry]::ClassesRoot } 'HKCU:*' { [Microsoft.Win32.Registry]::CurrentUser } 'HKLM:*' { [Microsoft.Win32.Registry]::LocalMachine } 'HKPD:*' { [Microsoft.Win32.Registry]::PerformanceData } 'HKU:*' { [Microsoft.Win32.Registry]::Users } default { throw } } $PathWithoutPrefix = $Path -replace '^HK(LM|CU|CR|U|CC):','' $RegKey = $Path -replace ':','\' -replace '\\\\','\' $RegKeyObj = $Hive.OpenSubKey($PathWithoutPrefix.TrimStart('\')) foreach ($Prop in $(Get-Item -ErrorAction SilentlyContinue $Path | Select-Object -ExpandProperty Property)) { DoWriteRegCommand $RegKeyObj $Prop $RegKey } } <# .SYNOPSIS Convert a registry path to a series of reg commands for copying into a script. .DESCRIPTION By default only HKCU: and HKLM: are mounted in PowerShell. Others need to be mounted and must be under the appropriate name such as HKU for HKEY_USERS. Keys are skipped under these conditions: - Depth limit (20); this can be changed by passing -MaxDepth or -m - Key that cannot be read for any reason such as permissions. - Value contains newlines An example of an always skipped key under normal circumstances is HKLM\SECURITY, even if this is run as administrator. .PARAMETER MaxDepth Depth limit. .PARAMETER Path Registry path. .PARAMETER Depth For internal use. Do not pass a value. .NOTES WARNING: If you save an entire tree such as HKLM to a file and attempt to run said script, you probably will break your OS. The output of this tool is meant for getting a single command at time, testing it, and then using it in an appropriate script. The author will not be held responsible for any damages. .EXAMPLE PS> Write-RegCommands 'HKCU:\Control Panel\Desktop' reg add ... .EXAMPLE PS> prefs-export 'HKCU:\Control Panel\Desktop' reg add ... #> function Write-RegCommands { param( [Parameter(Mandatory,HelpMessage = "Registry path.")] [ValidatePattern('^^HK(LM|CU|CR|U|CC):')] [string]$Path, [Parameter(HelpMessage = "Depth limit.")] [Alias("m")] [int]$MaxDepth = 20, [Parameter(HelpMessage = "Current depth level. Used internally.")] [int]$Depth) begin { $SkipRe = '(^HK..:.*\\CurrentVersion\\Explorer\\.*MRU.*)|(\\\*$)|' + ` '(.*\\Shell\\Bags\\[0-9]+\\Shell\\\{.*)' } process { if ($Depth -ge $MaxDepth) { Write-Debug "Skipping $Path due to depth limit of $MaxDepth." return } if ($Path -match $SkipRe) { Write-Debug "Skipping $Path because it matched the skip RE." continue } try { # SilentlyContinue is needed to skip HKLM\SECURITY $Items = Get-ChildItem -ErrorAction SilentlyContinue -Path $Path } catch { Write-Debug "Skipping $Path. Does the location exist?" return } if (!$Items) { $out = DoWriteRegCommands $(ReplaceFullHiveNameWithShortName $Path) if (!$out) { # Assume it is a full path to a value $Hive = switch -Wildcard ($Path) { 'HKCC:*' { [Microsoft.Win32.Registry]::CurrentConfig } 'HKCR:*' { [Microsoft.Win32.Registry]::ClassesRoot } 'HKCU:*' { [Microsoft.Win32.Registry]::CurrentUser } 'HKLM:*' { [Microsoft.Win32.Registry]::LocalMachine } 'HKPD:*' { [Microsoft.Win32.Registry]::PerformanceData } 'HKU:*' { [Microsoft.Win32.Registry]::Users } default { throw } } $Components = $($Path -replace '^HK(LM|CU|CR|U|CC):','').TrimStart('\').Split('\') $RegKeyObj = $Hive.OpenSubKey($($Components[0..($Components.Length - 2)] -join '\')) DoWriteRegCommand $RegKeyObj $($Path.Split('\')[-1]) $($Path -replace ':','') } else { Write-Output $out } return } foreach ($Item in $Items) { $ItemStr = $Item.ToString() $PathShort = ReplaceFullHiveNameWithShortName $ItemStr try { $Children = Get-ChildItem -Path $PathShort -ErrorAction SilentlyContinue } catch { Write-Debug "Skipping $Path because Get-ChildItem failed." continue } if ($Children) { Write-RegCommands -Path $PathShort -Depth $($Depth + 1) -MaxDepth $MaxDepth } else { DoWriteRegCommands -Path $PathShort } } } } <# .SYNOPSIS Save registry content as reg commands to an output directory. .DESCRIPTION This will save all reg commands generated by Write-RegCommands, sort them, and place them in a directory. It can also commit and push the changes automatically. .PARAMETER Commit If set, commits to Git repository at output directory. .PARAMETER DeployKey Key for pushing to Git repository. Requires -Commit. .PARAMETER MaxDepth Depth limit. .PARAMETER OutputDirectory Where to store the exported data. .PARAMETER Path Registry path. #> function Save-Preferences { param( [Parameter(HelpMessage = "Commit the changes with Git.")] [Alias("c")] [switch]$Commit = $false, [Parameter(HelpMessage = "Key for pushing to Git repository.")] [Alias("K")] [string]$DeployKey, [Parameter(HelpMessage = "Where to store the exported data.")] [Alias("o")] [string]$OutputDirectory = "${env:APPDATA}\prefs-export", [Parameter(HelpMessage = "Depth limit.")] [Alias("m")] [int]$MaxDepth = 20, [Parameter(HelpMessage = "Registry path.")] [ValidatePattern('^^HK(LM|CU|CR|U|CC):')] [string]$Path = 'HKCU:' ) if ($DeployKey) { $DeployKey = Resolve-Path -Path $DeployKey -ErrorAction SilentlyContinue } New-Item -Force -ItemType directory -Path "$OutputDirectory" | Out-Null Write-RegCommands -MaxDepth $MaxDepth -Path $Path | ` Sort-Object -CaseSensitive -Unique > "$OutputDirectory\exec-reg.bat" $Git = (Get-Command git).Path if ($Commit -and $Git) { if (-not (Test-Path -PathType Container -Path ".git")) { Write-Debug "Init" $OriginalLocation = Get-Location Set-Location $OutputDirectory git init Set-Location -Path $OriginalLocation } Write-Debug "Committing changes" git "--git-dir=$OutputDirectory\.git" "--work-tree=$OutputDirectory" add . git "--git-dir=$OutputDirectory\.git" "--work-tree=$OutputDirectory" commit --no-gpg-sign ` --quiet --no-verify "--author=winprefs <winprefs@tat.sh>" ` -m "Automatic commit @ $(Get-Date -UFormat %c)" if (Test-Path -PathType Leaf -Path $DeployKey) { git "--git-dir=$OutputDirectory\.git" "--work-tree=$OutputDirectory" config core.sshCommand ` "ssh -i ${DeployKey} -F nul -o UserKnownHostsFile=nul -o StrictHostKeyChecking=no" git "--git-dir=$OutputDirectory\.git" "--work-tree=$OutputDirectory" push -u --porcelain ` --no-signed origin origin $(git branch --show-current) } } } function GetSafePathName { param( [Parameter(Mandatory)] [string]$Path ) $Path -replace ':','-' -replace '\\','-' -replace '-+$','' } <# .SYNOPSIS Create and register a new scheduled task to save preferences. .DESCRIPTION The name of the task will be of format 'SavePreferences-<FIXED PATH>'. The default task is named SavePreferences-HKCU. A task to save 'HKCU:\Control Panel' would be named 'SavePreferences HKCU-Control Panel'. The task is run every 12 hours starting on the next day at midnight (00:00). Execution-Policy has to be modified for this to work. It must be at least Bypass. .PARAMETER DeployKey Key for pushing to Git repository. .PARAMETER MaxDepth Depth limit. .PARAMETER OutputDirectory Where to store the exported data. This can be an unexpanded string containing variable references. .PARAMETER Path Registry path. .EXAMPLE PS> Register-SavePreferencesScheduledTask #> function Register-SavePreferencesScheduledTask { param( [Parameter(HelpMessage = "Key for pushing to Git repository.")] [Alias("K")] [string]$DeployKey, [Parameter(HelpMessage = "Depth limit.")] [Alias("m")] [int]$MaxDepth = 20, [Parameter( HelpMessage = "Where to store the exported data." )] [Alias("o")] [string]$OutputDirectory = '${env:APPDATA}\prefs-export', [Parameter(HelpMessage = "Registry path.")] [ValidatePattern('^^HK(LM|CU|CR|U|CC):')] [string]$Path = 'HKCU:' ) $TasksDir = "${env:APPDATA}\WinPrefs\tasks" New-Item -Force -ItemType directory -Path $TasksDir | Out-Null $SafePathName = GetSafePathName $Path $TaskFile = "$TasksDir\export-$(($SafePathName).ToLower()).ps1" if (Test-Path -PathType leaf $TaskFile) { Write-Warning 'Task file for this path already exists. Overwriting.' } $DeployKeyArg = if ($DeployKey -and (Test-Path -PathType leaf $DeployKey)) { " -DeployKey ""$DeployKey""" } else { '' } Write-Output "Import-Module WinPrefs" > $TaskFile Write-Output "Save-Preferences -Commit$DeployKeyArg -MaxDepth $MaxDepth ``" >> $TaskFile Write-Output " -OutputDirectory ""$OutputDirectory"" -Path ""$Path""" >> $TaskFile $TaskName = "SavePreferences-$SafePathName" $TaskPath = "\tat.sh\WinPrefs\" if (Get-ScheduledTaskInfo -TaskName $TaskName -TaskPath $TaskPath) { # Only updating the script is necessary. Write-Output -NoEnumerate ` "Task already exists. If you want to restore to the default task settings, you must delete the existing task '$TaskPath\$TaskName' in Task Scheduler. You can also run Unregister-SavePreferencesScheduledTask with the same -Path argument." return } $Action = New-ScheduledTaskAction ` -Argument "-ExecutionPolicy Bypass -NoLogo -NonInteractive -NoProfileLoadTime -WindowStyle Hidden -File $TaskFile" ` -Execute 'pwsh.exe' ` -WorkingDirectory $HOME $Trigger = New-ScheduledTaskTrigger ` -At ([datetime]::Today.AddDays(1)) ` -Once ` -RandomDelay (New-TimeSpan -Minutes 30) ` -RepetitionInterval (New-TimeSpan -Hours 12) $Settings = New-ScheduledTaskSettingsSet ` -MultipleInstances IgnoreNew ` -ExecutionTimeLimit (New-TimeSpan -Hours 2) ` -StartWhenAvailable Register-ScheduledTask ` -Action $Action ` -Description "Run SavePreferences every 12 hours (path $Path)." ` -Force ` -Settings $Settings ` -TaskName "SavePreferences-$SafePathName" ` -TaskPath $TaskPath ` -Trigger $Trigger } <# .SYNOPSIS Remove an existing scheduled task for a registry path. .DESCRIPTION This also cleans up tat.sh/WinPrefs scheduled task directory trees and WinPrefs-related directories in APPDATA. .PARAMETER Path Registry path. .EXAMPLE PS> Unregister-SavePreferencesScheduledTask #> function Unregister-SavePreferencesScheduledTask { param( [Parameter(HelpMessage = "Registry path.")] [ValidatePattern('^^HK(LM|CU|CR|U|CC):')] [string]$Path = 'HKCU:' ) $SafePathName = GetSafePathName $Path $TaskName = "SavePreferences-$SafePathName" $TasksDir = "${env:APPDATA}\WinPrefs\tasks" $TaskFile = "$TasksDir\export-$(($SafePathName).ToLower()).ps1" $TaskPath = "\tat.sh\WinPrefs\" if (Get-ScheduledTaskInfo -TaskName $TaskName -TaskPath $TaskPath) { Disable-ScheduledTask -TaskName $TaskName -TaskPath $TaskPath Stop-ScheduledTask -TaskName $TaskName -TaskPath $TaskPath Unregister-ScheduledTask -TaskName $TaskName -TaskPath $TaskPath -Confirm:$false Remove-Item -Force -Path $TaskFile -Confirm:$false } # If everything is empty under tat.sh\WinPrefs delete the directories. if (-not (Get-ScheduledTask | Where-Object { $_.TaskPath -eq '\tat.sh\WinPrefs\' })) { $ScheduleObject = New-Object -ComObject Schedule.Service $ScheduleObject.connect() $ScheduleObject.GetFolder('\').DeleteFolder('tat.sh\WinPrefs',$null) if (-not (Get-ScheduledTask | Where-Object { $_.TaskPath -eq '\tat.sh\' })) { # Again for tat.sh $ScheduleObject.GetFolder('\').DeleteFolder('tat.sh',$null) } } if (((Get-ChildItem -Path $TasksDir -Force) | Measure-Object).Count -eq 0) { Remove-Item -Force -Confirm:$false -Path $TasksDir $PrefsDir = "${env:APPDATA}\WinPrefs" if (((Get-ChildItem -Path $PrefsDir -Force) | Measure-Object).Count -eq 0) { Remove-Item -Force -Confirm:$false -Path $PrefsDir } } } Set-Alias -Name path2reg -Value Write-RegCommands Set-Alias -Name prefs-export -Value Save-Preferences Set-Alias -Name winprefs-install-job -Value Register-SavePreferencesScheduledTask Set-Alias -Name winprefs-uninstall-job -Value Unregister-SavePreferencesScheduledTask Export-ModuleMember -Alias path2reg,prefs-export,winprefs-install-job,winprefs-uninstall-job ` -Function Register-SavePreferencesScheduledTask,Save-Preferences,` Unregister-SavePreferencesScheduledTask,Write-RegCommands |