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 '%', '%%' # -Replace "(`r)?`n", "!LF!"
}

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. 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.

  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
    # Dump reg commands for desktop settings.
    Write-RegCommands 'HKCU:\Control Panel\Desktop'

    # Use the alias.
    prefs-export 'HKCU:\Control Panel\Desktop'

    # Dump the entire HKLM (note skipped keys above) and save to a script
    prefs-export HKLM: > hklm.bat
#>

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
      }
    }
  }
}

function Save-Preferences {
  param(
    [Parameter(HelpMessage = "Key for pushing to Git repository.")]
    [Alias("K")]
    [string]$DeployKey,

    [Parameter(HelpMessage = "Commit the changes with Git.")]
    [Alias("c")]
    [switch]$Commit = $false,

    [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)
    }
  }
}

Set-Alias -Name path2reg -Value Write-RegCommands
Set-Alias -Name prefs-export -Value Save-Preferences
Export-ModuleMember -Alias path2reg, prefs-export -Function Save-Preferences, Write-RegCommands