Private/WtSettings.ps1
|
# Surgical editing of the Windows Terminal settings.json. # # The principle shared by all three installers (theme, opaque backdrop, system # title bar): the document is never re-serialised. Only the strict minimum is # inserted or replaced in the existing text, so that comments, key order and the # user's formatting are preserved. The result is validated before any write, and a # backup is taken. # # The locating helpers work on a [masked] copy of the text: same length as the # original, comments and escape sequences neutralised, so that any index found in # the copy is directly usable on the original. function Get-TcMaskedJson { <# .SYNOPSIS Copy of the text without comments and without escape sequences, the same length as the original: indexes found in this copy are therefore directly usable on the original text. #> param([string] $Text) $noComments = Remove-TcJsonComments -Text $Text $chars = $noComments.ToCharArray() $inString = $false for ($i = 0; $i -lt $chars.Length; $i++) { $c = $chars[$i] if ($inString) { if ($c -eq '\') { $chars[$i] = ' '; if ($i + 1 -lt $chars.Length) { $chars[$i + 1] = ' '; $i++ }; continue } if ($c -eq '"') { $inString = $false; continue } # String contents are masked, except for the key names we want to be # able to find again: the characters are therefore kept. continue } if ($c -eq '"') { $inString = $true } } return (-join $chars) } function Find-TcJsonObjectSpan { <# .SYNOPSIS Given an index inside a JSON object, returns @{ Start; End } delimiting that object (braces included). #> param([string] $Text, [int] $Index) # Walk back to the current object's opening brace $depth = 0 $start = -1 for ($i = $Index; $i -ge 0; $i--) { $c = $Text[$i] if ($c -eq '}') { $depth++ } elseif ($c -eq '{') { if ($depth -eq 0) { $start = $i; break } $depth-- } } if ($start -lt 0) { return $null } $depth = 0 for ($i = $start; $i -lt $Text.Length; $i++) { $c = $Text[$i] if ($c -eq '{') { $depth++ } elseif ($c -eq '}') { $depth-- if ($depth -eq 0) { return @{ Start = $start; End = $i } } } } return $null } function Find-TcJsonBlockSpan { <# .SYNOPSIS Returns @{ Start; End } of the object or array starting at $Index (the index must point at [{] or [[]). #> param([string] $Text, [int] $Index) $open = $Text[$Index] if ($open -eq '{') { $close = '}' } elseif ($open -eq '[') { $close = ']' } else { return $null } $depth = 0 for ($i = $Index; $i -lt $Text.Length; $i++) { $c = $Text[$i] if ($c -eq $open) { $depth++ } elseif ($c -eq $close) { $depth-- if ($depth -eq 0) { return @{ Start = $Index; End = $i } } } } return $null } function Find-TcJsonMember { <# .SYNOPSIS Looks for the [Name] member directly inside the object delimited by $Span (depth 1: nested objects are ignored). Returns @{ NameStart; ValueStart; ValueEnd } or $null. ValueEnd is the index of the value's last character. #> param([string] $Masked, [hashtable] $Span, [string] $Name) $pattern = '"' + [regex]::Escape($Name) + '"\s*:' foreach ($m in [regex]::Matches($Masked, $pattern)) { if ($m.Index -le $Span.Start -or $m.Index -ge $Span.End) { continue } # Check that the member really is at the root of this object, and not # inside a nested object or array. $depth = 0 for ($i = $Span.Start + 1; $i -lt $m.Index; $i++) { $c = $Masked[$i] if ($c -eq '{' -or $c -eq '[') { $depth++ } elseif ($c -eq '}' -or $c -eq ']') { $depth-- } } if ($depth -ne 0) { continue } # Start of the value $v = $m.Index + $m.Length while ($v -lt $Span.End -and [char]::IsWhiteSpace($Masked[$v])) { $v++ } if ($v -ge $Span.End) { continue } $end = -1 $c = $Masked[$v] if ($c -eq '"') { for ($i = $v + 1; $i -le $Span.End; $i++) { if ($Masked[$i] -eq '"') { $end = $i; break } } } elseif ($c -eq '{' -or $c -eq '[') { $block = Find-TcJsonBlockSpan -Text $Masked -Index $v if ($block) { $end = $block.End } } else { # Literal: number, true, false, null for ($i = $v; $i -le $Span.End; $i++) { $ch = $Masked[$i] if ($ch -eq ',' -or $ch -eq '}' -or $ch -eq ']') { break } $end = $i } while ($end -gt $v -and [char]::IsWhiteSpace($Masked[$end])) { $end-- } } if ($end -lt 0) { continue } return @{ NameStart = $m.Index; ValueStart = $v; ValueEnd = $end } } return $null } function Test-TcJsonObjectEmpty { param([string] $Masked, [hashtable] $Span) for ($i = $Span.Start + 1; $i -lt $Span.End; $i++) { if (-not [char]::IsWhiteSpace($Masked[$i])) { return $false } } return $true } function Set-TcJsonMember { <# .SYNOPSIS Adds or replaces the [Name] member of the object containing $AnchorIndex. .DESCRIPTION $Literal is inserted as-is: it is up to the caller to supply a valid JSON literal (quoted string, number, true/false). Returns @{ Text; Action } where Action is 'added', 'replaced' or 'unchanged'. Since any modification invalidates the indexes, only one member is applied per call. #> param( [string] $Text, [int] $AnchorIndex, [string] $Name, [string] $Literal, [string] $Indent = ' ' ) $masked = Get-TcMaskedJson -Text $Text $span = Find-TcJsonObjectSpan -Text $masked -Index $AnchorIndex if ($null -eq $span) { throw "TerminalColors: JSON object not found for member [$Name]." } $member = Find-TcJsonMember -Masked $masked -Span $span -Name $Name if ($member) { $current = $Text.Substring($member.ValueStart, $member.ValueEnd - $member.ValueStart + 1) if ($current -eq $Literal) { return @{ Text = $Text; Action = 'unchanged' } } $newText = $Text.Remove($member.ValueStart, $member.ValueEnd - $member.ValueStart + 1).Insert($member.ValueStart, $Literal) return @{ Text = $newText; Action = 'replaced' } } $fragment = '"' + $Name + '": ' + $Literal if (Test-TcJsonObjectEmpty -Masked $masked -Span $span) { # Empty object: rewrite it whole so the closing brace does not end up # glued to the inserted value. $closeIndent = $Indent if ($closeIndent.Length -ge 4) { $closeIndent = $closeIndent.Substring(4) } $block = '{' + [Environment]::NewLine + $Indent + $fragment + [Environment]::NewLine + $closeIndent + '}' $newText = $Text.Remove($span.Start, $span.End - $span.Start + 1).Insert($span.Start, $block) } else { $newText = $Text.Insert($span.Start + 1, [Environment]::NewLine + $Indent + $fragment + ',') } return @{ Text = $newText; Action = 'added' } } function Remove-TcJsonMember { <# .SYNOPSIS Removes the [Name] member from the object containing $AnchorIndex, absorbing the adjacent comma to keep the object valid. Returns @{ Text; Action } where Action is 'removed' or 'absent'. #> param( [string] $Text, [int] $AnchorIndex, [string] $Name ) $masked = Get-TcMaskedJson -Text $Text $span = Find-TcJsonObjectSpan -Text $masked -Index $AnchorIndex if ($null -eq $span) { return @{ Text = $Text; Action = 'absent' } } $member = Find-TcJsonMember -Masked $masked -Span $span -Name $Name if ($null -eq $member) { return @{ Text = $Text; Action = 'absent' } } $start = $member.NameStart $end = $member.ValueEnd $after = $end + 1 while ($after -lt $span.End -and [char]::IsWhiteSpace($masked[$after])) { $after++ } if ($after -lt $span.End -and $masked[$after] -eq ',') { $end = $after } else { $before = $start - 1 while ($before -gt $span.Start -and [char]::IsWhiteSpace($masked[$before])) { $before-- } if ($before -gt $span.Start -and $masked[$before] -eq ',') { $start = $before } } # Absorb the indentation left at the start of the line. $probe = $start - 1 while ($probe -gt $span.Start -and ($masked[$probe] -eq ' ' -or $masked[$probe] -eq "`t")) { $probe-- } if ($probe -gt $span.Start -and $masked[$probe] -eq "`n") { $start = $probe } return @{ Text = $Text.Remove($start, $end - $start + 1); Action = 'removed' } } function Find-TcJsonRootIndex { param([string] $Masked) $index = $Masked.IndexOf('{') if ($index -lt 0) { throw 'TerminalColors: unexpected settings.json structure (no root object).' } return $index } function Resolve-TcWtSettingsPath { <# .SYNOPSIS Validates a supplied settings.json path, or detects one. Throws an explicit error when the file cannot be found. #> param([string] $Path) if (-not $Path) { $Path = Get-TcWtSettingsPath } if (-not $Path -or -not [System.IO.File]::Exists($Path)) { throw 'TerminalColors: Windows Terminal settings.json not found. Start Windows Terminal once, or pass -SettingsPath.' } return $Path } function Get-TcWtSettingsEncoding { param([string] $Path) try { $bytes = [System.IO.File]::ReadAllBytes($Path) $hasBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) } catch { $hasBom = $false } return (New-Object System.Text.UTF8Encoding($hasBom)) } function Save-TcWtSettings { <# .SYNOPSIS Writes settings.json preserving its encoding, after taking a timestamped backup. Returns the backup path, or $null. #> param( [string] $Path, [string] $Text, [switch] $NoBackup ) $backupPath = $null if (-not $NoBackup) { $backupPath = '{0}.terminalcolors-backup-{1}' -f $Path, (Get-Date -Format 'yyyyMMdd-HHmmss') [System.IO.File]::Copy($Path, $backupPath, $true) } [System.IO.File]::WriteAllText($Path, $Text, (Get-TcWtSettingsEncoding -Path $Path)) return $backupPath } # --- Module data ------------------------------------------------------------- # Folder holding the install state and the opaque backdrop images. Redirectable so # that the tests never touch the user's real data (uninstalling the backdrop # deletes files in there). $script:TcDataDirectory = $null function Get-TcDataDirectory { if ($script:TcDataDirectory) { return $script:TcDataDirectory } return (Join-Path $env:LOCALAPPDATA 'TerminalColors') } # --- Install state ----------------------------------------------------------- # Remembers what has to be restored on uninstall (previous theme, previous # background image, previous value of showTabsInTitlebar). function Get-TcInstallStatePath { return (Join-Path (Get-TcDataDirectory) 'state.json') } function Set-TcInstallState { param([string] $Name, $Value) $path = Get-TcInstallStatePath $dir = Split-Path $path -Parent if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $state = @{} if ([System.IO.File]::Exists($path)) { $existing = ConvertFrom-TcJsonFile -Path $path if ($existing) { foreach ($p in $existing.PSObject.Properties) { $state[$p.Name] = $p.Value } } } $state[$Name] = $Value $encoding = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($path, ([pscustomobject]$state | ConvertTo-Json -Depth 5), $encoding) } function Get-TcInstallState { param([string] $Name) $path = Get-TcInstallStatePath if (-not [System.IO.File]::Exists($path)) { return $null } $state = ConvertFrom-TcJsonFile -Path $path if (-not $state) { return $null } return (Get-TcJsonProperty -InputObject $state -Name $Name) } function Remove-TcInstallState { param([string] $Name) $path = Get-TcInstallStatePath if (-not [System.IO.File]::Exists($path)) { return } $state = ConvertFrom-TcJsonFile -Path $path if (-not $state) { return } $kept = @{} foreach ($p in $state.PSObject.Properties) { if ($p.Name -ne $Name) { $kept[$p.Name] = $p.Value } } $encoding = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($path, ([pscustomobject]$kept | ConvertTo-Json -Depth 5), $encoding) } |