lib/wtsettings.ps1
|
# wtsettings.ps1 -- reading, merging and writing Windows Terminal's settings.json. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # One of exactly two config writers in the project (the other is # New-AppleTerminalProfile in terminals.ps1); everything else is escape # sequences. Three concerns live here and each has a sharp edge: # # JSONC -- Windows Terminal ships settings.json with comments and trailing # commas, which Windows PowerShell 5.1's ConvertFrom-Json rejects. # Hand-written string state machines strip them. # Merging -- Merge-StyleIntoSettings decides what a style may change and, # via Test-ManagedBackgroundPath, whose background it is allowed # to clear. # Writing -- atomic replace through a sibling temp file, at depth 100, # because the default depth silently stringifies a deep file. function Remove-JsonComment { # Strip // line comments and /* */ block comments from a JSON/JSONC string, # leaving comment-like sequences INSIDE string literals (URLs, globs, paths) # intact. Windows Terminal's default settings.json ships with // comments, # which Windows PowerShell 5.1's ConvertFrom-Json rejects; this normalizes the # text so 5.1 and pwsh 7 parse identically. Round-tripping back through # ConvertTo-Json drops comments anyway, so stripping costs no fidelity. param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) $sb = [System.Text.StringBuilder]::new($Text.Length) $inString = $false $escaped = $false $i = 0 $n = $Text.Length while ($i -lt $n) { $c = $Text[$i] if ($inString) { [void]$sb.Append($c) if ($escaped) { $escaped = $false } elseif ($c -eq '\') { $escaped = $true } elseif ($c -eq '"') { $inString = $false } $i++ continue } if ($c -eq '"') { $inString = $true [void]$sb.Append($c) $i++ continue } if ($c -eq '/' -and ($i + 1) -lt $n) { $next = $Text[$i + 1] if ($next -eq '/') { # Line comment: skip to (but keep) the newline. $i += 2 while ($i -lt $n -and $Text[$i] -ne "`n") { $i++ } continue } if ($next -eq '*') { # Block comment: skip through the closing */. $i += 2 while ($i -lt $n -and -not ($Text[$i] -eq '*' -and ($i + 1) -lt $n -and $Text[$i + 1] -eq '/')) { $i++ } $i += 2 continue } } [void]$sb.Append($c) $i++ } $sb.ToString() } function Remove-JsonTrailingComma { # Drop trailing commas (a ',' whose next non-whitespace char is '}' or ']') # OUTSIDE string literals. Windows Terminal and hand edits leave these; pwsh 7 # tolerates them but Windows PowerShell 5.1's ConvertFrom-Json rejects them # with "extra trailing ','", crashing every mutating command. Commas inside # string values (e.g. "a,b") are preserved. Run AFTER Remove-JsonComment so a # comment between the comma and the bracket can't hide the trailing comma. param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) $sb = [System.Text.StringBuilder]::new($Text.Length) $inString = $false $escaped = $false $i = 0 $n = $Text.Length while ($i -lt $n) { $c = $Text[$i] if ($inString) { [void]$sb.Append($c) if ($escaped) { $escaped = $false } elseif ($c -eq '\') { $escaped = $true } elseif ($c -eq '"') { $inString = $false } $i++ continue } if ($c -eq '"') { $inString = $true [void]$sb.Append($c) $i++ continue } if ($c -eq ',') { # Peek past whitespace: a comma immediately preceding } or ] is trailing. $j = $i + 1 while ($j -lt $n -and [char]::IsWhiteSpace($Text[$j])) { $j++ } if ($j -lt $n -and ($Text[$j] -eq '}' -or $Text[$j] -eq ']')) { $i++ # skip the comma; leave the whitespace/bracket intact continue } } [void]$sb.Append($c) $i++ } $sb.ToString() } function ConvertFrom-WTJson { # Parse a Windows Terminal settings.json string, tolerating the // and /* */ # comments and trailing commas WT writes by default. On Windows PowerShell 5.1 # ConvertFrom-Json rejects both outright, so a fresh WT install (or a hand edit) # would otherwise abort every mutating command with a raw parse error. Strips # comments first, then trailing commas, then parses; throws one actionable # message if the text still isn't valid JSON. param([Parameter(Mandatory)][AllowEmptyString()][string]$Json) $clean = Remove-JsonComment -Text $Json $clean = Remove-JsonTrailingComma -Text $clean try { $clean | ConvertFrom-Json } catch { throw ("TerminalStyles: could not parse Windows Terminal settings.json. " + "On Windows PowerShell 5.1, JSON comments other than // and /* */ are not supported -- " + "open WT Settings and Save once, or remove the offending text. " + "Underlying error: $($_.Exception.Message)") } } function Test-ManagedBackgroundPath { # True when a profile's backgroundImage points at a file TerminalStyles put # there itself: a bundled styles\<name>\background.* under the module root, # or a lazily-fetched copy under the data root's cache\. Anything else -- # the user's own image, or Windows Terminal keywords like # 'desktopWallpaper' -- is theirs, and the merge leaves it alone. # # This is what lets a bundle-less style clear the PREVIOUS style's # background without also clobbering a background the user chose. # # It is the ownership proof for a DELETE from a file the user owns, so it # says "ours" only for an ABSOLUTE path under one of those roots -- see the # guard below for the other shapes Windows Terminal accepts here. param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } # Windows Terminal accepts more than absolute paths here: the keyword # 'desktopWallpaper', a relative path, and environment-variable forms like # '%USERPROFILE%\Pictures\bg.png'. None of those is a path this module could # have written -- every background it writes is Join-Path'd onto an absolute # $script:TStylesModuleRoot or $script:TStylesDataRoot -- so all of them are # the user's, and saying so here is what keeps them. # # Saying so is also the only safe answer, because [Path]::GetFullPath below # resolves a non-absolute value against the PROCESS working directory # ([Environment]::CurrentDirectory, which Set-Location does NOT move -- a # child pwsh inherits it from the parent's location instead). Launch pwsh # inside the clone, the data root, or a ...\Modules\TerminalStyles parent -- # `cd TerminalStyles; pwsh -File .\apply.ps1 ...` does exactly that -- and # 'desktopWallpaper' normalises to <cwd>\desktopWallpaper, lands under a # root, and the user's own background is stripped by the guard that exists # to protect it. if (-not [System.IO.Path]::IsPathRooted($Path)) { return $false } # Rooted is not quite enough on Windows: 'C:bg.png' carries a drive but no # separator, so it resolves through THAT DRIVE's current directory and is # CWD-dependent in the same way. [Path]::IsPathFullyQualified would rule out # both shapes in one call, but it is .NET Core only and CI runs Windows # PowerShell 5.1 on .NET Framework. On Unix nothing reaches this line but a # leading '/', which cannot match. if ($Path -match '^[A-Za-z]:(?![\\/])') { return $false } $roots = [System.Collections.Generic.List[string]]::new() foreach ($r in @($script:TStylesModuleRoot, $script:TStylesDataRoot)) { if (-not [string]::IsNullOrWhiteSpace($r)) { $roots.Add($r) } } # PSResourceGet installs each version to its own sibling dir # (...\Modules\TerminalStyles\<version>\), so a background written by an # earlier version sits OUTSIDE the current module root. Treat the whole # ...\Modules\TerminalStyles\ tree as ours -- but only when the parent is # literally named TerminalStyles, so this can't swallow a neighbouring # module's files. $parent = Split-Path $script:TStylesModuleRoot -Parent if ($parent -and (Split-Path $parent -Leaf) -eq 'TerminalStyles') { $roots.Add($parent) } foreach ($root in $roots) { if ([string]::IsNullOrWhiteSpace($root)) { continue } try { # GetFullPath normalises separators/casing-insensitive comparison and # resolves any '..'; it does not require the file to exist. Only # absolute paths get this far -- the guard above rejected everything # whose resolution would depend on the process working directory. $full = [System.IO.Path]::GetFullPath($Path) $fullRoot = [System.IO.Path]::GetFullPath($root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar } catch { continue # unparseable path (invalid chars) -- treat as not ours } # Compare against the root WITH a trailing separator so a sibling like # 'TerminalStylesEvil\x.gif' can't match the 'TerminalStyles' root. if ($full.StartsWith($fullRoot, [StringComparison]::OrdinalIgnoreCase)) { return $true } } return $false } function Get-StyleSettingsPayload { <# .SYNOPSIS Does this style have anything to write into settings.json? .DESCRIPTION The style half of the rule the -Target guard covers for profiles: find out BEFORE touching the user's file whether the operation can do anything. Merge-StyleIntoSettings returns the settings object UNTOUCHED for a style with no theme.json -- correctly, because a colour scheme is only reachable through a profile's colorScheme key, which theme.json carries, so writing the scheme anyway would strand it where Reset can never remove it. But every caller then wrote the returned object regardless, and that write is not a no-op: re-serializing what ConvertFrom-WTJson parsed drops every // and /* */ comment the user wrote. So applying a scheme-only style destroyed the comments in settings.json, applied nothing, and reported "Style applied" in green. A style with scheme.json and no theme.json is LEGAL -- README documents theme.json as optional, and off Windows Terminal scheme.json is the whole style -- so this is not an error, it is "nothing for THIS writer to do". A missing scheme.json is different: the directory changed under us, and the caller should say so rather than throw from inside the merge. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$StyleDir) if (-not (Test-Path -LiteralPath (Join-Path $StyleDir 'scheme.json'))) { return [pscustomobject]@{ Ok = $false; Missing = 'scheme.json' } } if (-not (Test-Path -LiteralPath (Join-Path $StyleDir 'theme.json'))) { return [pscustomobject]@{ Ok = $false; Missing = 'theme.json' } } return [pscustomobject]@{ Ok = $true; Missing = $null } } function Get-WTProfileShape { <# .SYNOPSIS What `profiles` actually is in this settings.json, and what a -Target may name. .DESCRIPTION Windows Terminal writes `"profiles": { "defaults": {...}, "list": [...] }` and every read here assumed it. Files in the wild are not always that shape: a hand-minimised settings.json can have no `profiles` key at all, `"profiles": null` parses to nothing, an interrupted or truncated write leaves a zero-byte file (so $Settings ITSELF is $null), and the legacy flat form `"profiles": [ ... ]` is an array. HasDefaultsSlot is the load-bearing one, because 'defaults' is the single target that does not have to exist yet: an apply creates the block lazily with `$Settings.profiles | Add-Member`. That has nothing to add to unless `profiles` is a JSON object. Against a missing key or a null it throws "You cannot call a method on a null-valued expression"; against the array form it is worse and does not throw at all, because Add-Member UNROLLS an array and grafts a `defaults` NoteProperty onto every profile IN it -- so the whole theme is written inside the first profile, where Windows Terminal ignores it. One question, four readers -- Resolve-WTProfileTarget decides Ok and Available from it, Merge-StyleIntoSettings and Set-ProfileFont guard their lazy creation with it, Reset-StyleDirect enumerates profiles with it. Sharing it is the point: the resolver answering "yes, addressable" while the merge assumed a shape the file did not have is what spent the user's rolling backup and then wrote a comment-stripped settings.json back over their own file, under "Style applied" in green. ConvertFrom-Json gives a PSCustomObject for `{...}` and an Object[] for `[...]`, on pwsh 7 and Windows PowerShell 5.1 alike. The test below asks the question structurally rather than by type name -- "is this something a member can be hung on", i.e. not null, not a collection, not a scalar -- because that is the property the lazy creation actually depends on. (Testing `-is [pscustomobject]` would be wrong anyway: that accelerator is System.Management.Automation.PSObject, which the parsed object is not.) #> [CmdletBinding()] param([Parameter(Mandatory)][AllowNull()]$Settings) $profiles = $null try { $profiles = $Settings.profiles } catch { $profiles = $null } $hasSlot = ($null -ne $profiles -and $profiles -isnot [System.Collections.IEnumerable] -and $profiles -isnot [System.ValueType]) $list = @() if ($hasSlot) { # @($null) is a one-element array holding $null, not an empty one, so a # profiles object with no `list` key would otherwise report one profile. $list = @($profiles.list | Where-Object { $null -ne $_ }) } elseif ($profiles -is [array]) { $list = @($profiles | Where-Object { $null -ne $_ }) } # What the user may pass to -Target, in the order they should see it. # 'defaults' is listed only where it can actually be written: offering it # for a file that cannot carry it is the error message promising the one # answer that is guaranteed to fail. $available = @() if ($hasSlot) { $available += 'defaults' } $available += @($list | ForEach-Object { $_.name } | Where-Object { $_ }) return [pscustomobject]@{ HasDefaultsSlot = $hasSlot List = $list Available = $available } } function Resolve-WTProfileTarget { <# .SYNOPSIS Resolve a -Target name against settings.json. Reports nothing; decides only. .DESCRIPTION One rule, four callers. Apply, reset, font and the picker each resolved the target themselves, and each did it at a different point relative to the damage -- which is how a mistyped -Target came to destroy the rolling backup on two of them while erroring cleanly on a third. Ok vs Entry is a real distinction, not a convenience. 'defaults' is ADDRESSABLE without existing (an apply creates the block lazily) so Ok can be $true with no Entry -- and reset has nothing to strip from a profile that is not there. Callers that write want Ok; callers that modify an existing entry want Entry. Addressable is not unconditional, though: it needs a `profiles` object to create the block on, which is Get-WTProfileShape's question. Available is for the caller's error message, so every one of them can name the same set of real profiles. TieBreak says HOW a duplicated name was resolved, because the caller has to tell the user: 'Session' when $PreferGuid picked one of the namesakes, 'First' when it could not and the file order decided. It is $null exactly when Ambiguous is $false. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowNull()]$Settings, [Parameter(Mandatory)][AllowEmptyString()][string]$TargetName, # The session's own profile GUID. Used ONLY to disambiguate between # profiles that share $TargetName -- never to override a different # -Target the user asked for explicitly. Ambient default with a seam, # like Test-InteractiveConsole, so every caller gets it right without # threading it through five call sites. [AllowEmptyString()][string]$PreferGuid = $env:WT_PROFILE_ID ) $shape = Get-WTProfileShape -Settings $Settings $list = $shape.List $available = $shape.Available if ($TargetName -eq 'defaults') { # Ok was $true here UNCONDITIONALLY, and that is the whole of the defect # this guard closes. Apply-StyleDirect's -Target guard asks this # question, so it passed for a settings.json with no `profiles` object; # the rolling backup was spent; Merge-StyleIntoSettings then threw "You # cannot call a method on a null-valued expression" on the lazy # creation. A method call on null aborts the STATEMENT, not the command, # so the assignment never happened, $settings still held the # parsed-but-unmerged object, and the write on the next line re- # serialized it: every JSONC comment in the user's file deleted, "Style # applied" in green, and no profile touched. Run it twice and the .bak # -- the one copy that still had the comments -- was overwritten with # the stripped file. See Get-WTProfileShape for the four shapes. $entry = $null if ($shape.HasDefaultsSlot -and $Settings.profiles.PSObject.Properties.Match('defaults').Count) { $entry = $Settings.profiles.defaults } return [pscustomobject]@{ Ok = $shape.HasDefaultsSlot Entry = $entry IsDefaults = $true Available = $available Ambiguous = $false TieBreak = $null } } # NOT $matches: that is an AUTOMATIC variable, rewritten by every -match # in the same scope. Correct here only because this function uses none -- # and one -match added above the reads below would silently replace the # profile list with regex capture strings. Measured: a 2-element list # became a 1-element list of [String] after a single unrelated -match. $named = @($list | Where-Object name -eq $TargetName) # Windows Terminal allows two profiles with the SAME name and different # GUIDs -- a hand-copied profile, or a dynamic/fragment profile colliding # with a manually defined one. Every resolution here was # `Select-Object -First 1`, and Get-CurrentWTProfileName finds the session's # entry by $env:WT_PROFILE_ID and then returns only its NAME, throwing the # GUID away. So `tstyles <style>` in the SECOND of two same-named profiles # styled the first one: the user's own window was unchanged, an unrelated # profile was silently restyled, "Style applied" printed in green, and # -Target could not rescue it because the two are indistinguishable by name. # `tstyles reset` then stripped the same wrong profile. # # The GUID only breaks a tie. If it names a profile whose name is NOT # $TargetName, the user asked for a different profile than the one they are # sitting in, and that request wins. $entry = $null $tieBreak = $null if ($named.Count -gt 1) { if ($PreferGuid) { $entry = $named | Where-Object { $_.guid -eq $PreferGuid } | Select-Object -First 1 } # Recorded HERE, where the choice is actually made. The note the callers # print used to assert "Applied to the one this session is running in" # on every ambiguous resolution -- false whenever $PreferGuid names a # THIRD profile (reset run from an Ubuntu tab, say), which is the # ordinary modern-WT shape. Re-deriving it at the call site would be a # second copy of this decision, so the decision reports itself. $tieBreak = if ($entry) { 'Session' } else { 'First' } } if (-not $entry) { $entry = $named | Select-Object -First 1 } return [pscustomobject]@{ Ok = [bool]$entry; Entry = $entry; IsDefaults = $false; Available = $available # True when the name alone was not enough to identify a profile. Ambiguous = ($named.Count -gt 1) # 'Session', 'First', or $null when there was no tie to break. TieBreak = $tieBreak } } function Get-WTTargetNotFoundMessage { <# .SYNOPSIS The one sentence every caller says when Resolve-WTProfileTarget says no. .DESCRIPTION Three callers wrote this message, two of them identically, and all three ended in "Available: " + the list -- which is a dead end when the list is EMPTY. That is not a hypothetical shape: it is exactly the settings.json that used to be accepted for -Target defaults and then cost the user their JSONC comments (no `profiles` key, a null one, a zero-byte file). Refusing it is the fix; refusing it with a sentence that stops mid-air would only move the confusion. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowNull()]$ResolvedTarget, [Parameter(Mandatory)][AllowEmptyString()][string]$TargetName ) $available = @() if ($ResolvedTarget) { $available = @($ResolvedTarget.Available) } if (-not $available.Count) { # "not an object" rather than "empty": `"profiles": {}` IS usable -- the # defaults block can be created in it -- so this list names only the # shapes that are not. return ("Windows Terminal's settings.json has no profile to apply to: its 'profiles' key is " + "missing, null, or not an object. Check the file, or let Windows Terminal rewrite it.") } return "Windows Terminal profile '$TargetName' not found. Available: $($available -join ', ')" } function Write-AmbiguousTargetNote { <# .SYNOPSIS Say that a -Target name matched more than one profile, and which one won. .DESCRIPTION Two Windows Terminal profiles may carry the same `name`, and from outside they are indistinguishable: -Target cannot separate them, so a user cannot check which one a command chose. Resolve-WTProfileTarget breaks the tie with the session's own GUID, which is almost always what was wanted -- and "almost always" is worth one line wherever it is acted on. It was acted on in five places and said in one. `tstyles <style>` printed the note; the picker, `tstyles reset`, `tstyles font` and the standalone apply.ps1 resolved the same tie in silence -- and reset is the one that matters most, because it strips every field an apply may write (padding, opacity, useAcrylic, font.face among them) off whichever namesake it picked and then reports "Reset '<name>' to its unstyled default." in green. So the note lives here, next to the resolver, and every caller that acts on a resolution reads it. $Verb is the caller's own word for what it did, so the sentence stays true for a reset as well as an apply. The second line is NOT unconditional. It claimed the session's profile every time, which is false when the tie could not be broken -- the session is in a third profile, or there is no WT_PROFILE_ID at all -- and that is precisely the sentence a user reads to decide whether the right profile was hit. The resolver records which branch it took; this only reports it. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowNull()]$ResolvedTarget, [Parameter(Mandatory)][AllowEmptyString()][string]$TargetName, # The caller's verb: 'Applied to', 'Reset', 'Applying to'. [string]$Verb = 'Used' ) if (-not $ResolvedTarget -or -not $ResolvedTarget.Ambiguous) { return } Write-Host " Note: more than one profile is named '$TargetName'." -ForegroundColor DarkGray if ($ResolvedTarget.TieBreak -eq 'Session') { Write-Host (" {0} the one this session is running in." -f $Verb) -ForegroundColor DarkGray } else { Write-Host (" {0} the first one in settings.json, guid {1}." -f $Verb, $ResolvedTarget.Entry.guid) -ForegroundColor DarkGray } } function Save-SettingsBackup { <# .SYNOPSIS Take the rolling settings.json.bak -- only once the operation is known possible. .DESCRIPTION Taking this backup is itself DESTRUCTIVE: there is one .bak, and writing it consumes the user's undo of their last real apply. So a command that turns out to do nothing must not take it. That is why -ResolvedTarget is MANDATORY and is checked here. It is not defensive typing -- it is the invariant made structural. A caller cannot take the backup before resolving the target, because it has nothing to pass until it has. `tstyles reset -Target <typo>` and `tstyles font <name> -Target <typo>` both used to copy settings.json over the backup and only then discover the profile did not exist, printing "nothing to reset" over the wreckage of the user's one-line undo. Copy-Item rather than a read/write round-trip, so the bytes are preserved exactly -- a BOM included. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, # The result of Resolve-WTProfileTarget for the operation about to run. [Parameter(Mandatory)][AllowNull()]$ResolvedTarget, # The picker and the tuner take this as crash-recovery behind a menu # that redraws every frame; announcing it would be noise. [switch]$Quiet ) if (-not $ResolvedTarget -or -not $ResolvedTarget.Ok) { throw "Save-SettingsBackup called before the target was known to be valid. This is a bug: the backup consumes the user's undo, so it must be taken only once the operation is known to be possible." } Copy-Item -LiteralPath $Path -Destination "$Path.bak" -Force -ErrorAction Stop if (-not $Quiet) { Write-Host "Backed up settings to: $Path.bak" -ForegroundColor Gray } } function Merge-StyleIntoSettings { param( $Settings, [string]$StyleDir, [string]$TargetName, [string]$BackgroundImage, [bool]$BackgroundImageProvided ) $scheme = [System.IO.File]::ReadAllText((Join-Path $StyleDir 'scheme.json'), [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json # Resolve / validate the target FIRST. A non-existent named profile must not # cause us to inject the color scheme -- that would leave an orphan scheme in # settings.json that Reset's cleanup can never remove (no profile references # it). 'defaults' is created lazily below, only when there's a theme to write. # Through the shared resolver, so the merge writes to the SAME profile every # other path resolved -- including the GUID tie-break above. Its own # first-match lookup is what actually wrote the style onto the wrong one of # two same-named profiles. $namedEntry = $null if ($TargetName -ne 'defaults') { $resolved = Resolve-WTProfileTarget -Settings $Settings -TargetName $TargetName if (-not $resolved.Ok) { return $Settings } # missing named target: leave settings untouched $namedEntry = $resolved.Entry } # Everything that could still make us bail happens BEFORE the scheme is # upserted. A scheme is only reachable through a profile's colorScheme key, # which lives in theme.json -- so writing the scheme and then discovering # there is no theme.json, or no profile entry to write it to, leaves a # scheme nothing references. Reset-StyleDirect cleans up the scheme named by # the profile it is resetting, so an unreferenced one can never be removed # and accumulates in settings.json on every apply. # # That is precisely the failure the target guard above was added to prevent; # the missing-theme.json route into it was left open next to it. $themePath = Join-Path $StyleDir 'theme.json' if (-not (Test-Path -LiteralPath $themePath)) { return $Settings } $theme = [System.IO.File]::ReadAllText($themePath, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json # The lazy creation, guarded rather than assumed. Add-Member needs a # `profiles` OBJECT to add the block to: on a missing key or a null this # line threw, and on the legacy flat-array form it unrolled the array and # grafted `defaults` onto every profile in it without a word. The resolver # now refuses 'defaults' for both shapes, so a caller that went through it # never arrives here -- this is the same rule stated where the write # happens, for the two callers that merge without resolving first (the # picker's preview and the tuner). Falling through to the existing # `if (-not $entry)` keeps the no-target contract: settings returned # untouched, and no orphan scheme, since the scheme is upserted below. $entry = if ($TargetName -eq 'defaults') { if ((Get-WTProfileShape -Settings $Settings).HasDefaultsSlot) { if (-not $Settings.profiles.PSObject.Properties.Match('defaults').Count) { $Settings.profiles | Add-Member -NotePropertyName defaults -NotePropertyValue ([pscustomobject]@{}) } $Settings.profiles.defaults } } else { $namedEntry } if (-not $entry) { return $Settings } if (-not $Settings.PSObject.Properties.Match('schemes').Count) { $Settings | Add-Member -NotePropertyName schemes -NotePropertyValue @() } $Settings.schemes = @($Settings.schemes | Where-Object { $_.name -ne $scheme.name }) + $scheme # Resolve effective background: # 1. User passed -BackgroundImage <path> -> use that # 2. User passed -BackgroundImage "" -> strip (remove fields entirely) # 3. Style ships a bundled background.* -> use that # 4. Otherwise -> leave user's existing bg alone $effectiveBg = $BackgroundImage $applyBg = $BackgroundImageProvided if (-not $applyBg) { $bundled = Get-StyleBundledBackground -StyleDir $StyleDir if ($bundled) { $effectiveBg = $bundled $applyBg = $true } } # Three actions for bg fields: # skip : don't touch them # remove : strip them from the profile (explicit empty path => disable) # apply : substitute the placeholder and write all bg fields # # When the new style ships no background, what happens depends on WHOSE # background is currently on the profile. One we wrote for the previously # applied style gets cleared -- otherwise it bleeds through and the new # style is shown behind the old style's GIF. A background the user set # themselves is left alone, which is what the skip is for. # # "On the profile" has to mean what the profile SHOWS, not what its own # entry happens to spell out: Windows Terminal resolves every named profile # against profiles.defaults, so an image written there by # `tstyles <style> -Target defaults` is live on this profile with nothing on # its entry to show for it. Read only the entry -- as this did -- and # $existingBg is $null, the ownership test is asked about nothing, the # action falls to 'skip', and the next bundle-less style applied to the # profile the user is sitting in is drawn behind the previous style's GIF. # Asked through the shared resolver rather than reached for directly, # because `profiles` is not always an object: against the legacy flat-array # form or a missing key, `$Settings.profiles.defaults` is a method call on # null. The resolver already answers "what entry does this target name # resolve to", for all four shapes, and hands back $null when there is none. $inheritedEntry = $null if ($TargetName -ne 'defaults') { $inheritedEntry = (Resolve-WTProfileTarget -Settings $Settings -TargetName 'defaults').Entry } $inheritedBg = if ($inheritedEntry -and $inheritedEntry.PSObject.Properties.Match('backgroundImage').Count -gt 0) { [string]$inheritedEntry.backgroundImage } else { $null } # The profile's own key shadows the inherited one, so it is the one that # answers "whose background is on screen". $existingBg = if ($entry.PSObject.Properties.Match('backgroundImage').Count -gt 0) { [string]$entry.backgroundImage } else { $inheritedBg } $bgAction = if ($applyBg) { if ([string]::IsNullOrEmpty($effectiveBg)) { 'remove' } else { 'apply' } } elseif (Test-ManagedBackgroundPath -Path $existingBg) { 'remove' } else { 'skip' } $bgFields = $script:TStylesBgFields # 'remove' is driven by what is already ON the profile, not by what the new # style's theme.json happens to mention -- and a style that ships no # background has no reason to mention background fields at all. Running this # inside the property loop below meant the clear only fired for styles whose # theme.json named the fields, so switching to one that omitted them left the # PREVIOUS style's image showing through the new palette. if ($bgAction -eq 'remove') { foreach ($bgField in $bgFields) { if ($entry.PSObject.Properties.Match($bgField).Count -gt 0) { $entry.PSObject.Properties.Remove($bgField) } } # And from profiles.defaults when that is where the image the profile # shows actually lives: stripping the entry alone hands the profile # straight back to the inherited copy, which is the same bleed one # level up. Guarded on the INHERITED value being ours, separately from # the decision above -- the entry can carry one of ours over a # background the user chose for every profile, and that one is theirs # to keep. Only 'remove' reaches here: an 'apply' writes the new # style's image onto the entry, where it shadows defaults, so nothing # of the old style is visible on this profile and clearing defaults # would silently restyle every other profile too. if ($inheritedEntry -and (Test-ManagedBackgroundPath -Path $inheritedBg)) { foreach ($bgField in $bgFields) { if ($inheritedEntry.PSObject.Properties.Match($bgField).Count -gt 0) { $inheritedEntry.PSObject.Properties.Remove($bgField) } } } } foreach ($prop in $theme.PSObject.Properties) { $name = $prop.Name $value = $prop.Value if ($name -in $bgFields) { # skip: leave the user's own background alone. # remove: already stripped above; re-adding it here would undo that. if ($bgAction -ne 'apply') { continue } if ($name -eq 'backgroundImage' -and $value -eq '{{BACKGROUND_IMAGE}}') { $value = $effectiveBg } } if ($entry.PSObject.Properties.Match($name).Count -gt 0) { $entry.$name = $value } else { $entry | Add-Member -NotePropertyName $name -NotePropertyValue $value -Force } } return $Settings } function Write-SettingsAtomic { # Write settings.json durably: serialize to a sibling temp file, then # atomically replace the live file. WriteAllText truncates-then-writes, so a # crash/kill or a concurrent reader (Windows Terminal watches settings.json # and reloads on change) can observe a half-written/empty file. A same-volume # rename is atomic on NTFS, so the live file is only ever the old bytes or the # complete new bytes -- never a truncated middle. Falls back to a direct copy # if Replace/Move is unsupported (e.g. an odd filesystem). UTF-8, keeping the # byte-order mark the live file already had and adding none to a new file. param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Json) # Whether settings.json starts with a BOM is the FILE's property, not this # caller's, and the single writer is the only place that can keep it. Every # snapshot in the module is taken with [File]::ReadAllText, whose overload # builds its StreamReader with detectEncodingFromByteOrderMarks:true no # matter which encoding it is handed -- so a leading EF BB BF is consumed as # an encoding marker and is simply not in the string. Writing # UTF8Encoding($false) unconditionally then dropped it, three bytes at a # time, from a file the user owns: the picker's Esc and the tuner's Esc are # both documented as restoring the ORIGINAL BYTES (tstyles.ps1 header, # README "reverts in-memory to the exact prior bytes"), and both came back # shorter than they went in. The rolling .bak is taken with Copy-Item # precisely so it keeps every byte, a BOM included -- which left the backup # and the live file disagreeing about the header. Sniffing here fixes the # preview write, the revert, apply, reset and the font writer in one place; # asking at the revert alone would still have lost it on the first preview. # # A missing, locked or unreadable file falls through to no BOM, which is # today's behaviour and the right default for a file we are creating. $hadBom = $false try { if (Test-Path -LiteralPath $Path) { $head = [byte[]]::new(3) # FileShare::ReadWrite, not OpenRead's FileShare::Read: Windows # Terminal watches settings.json and may hold it open, and an # exception here would silently cost the BOM on exactly the # machines this runs on. $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) try { $read = $fs.Read($head, 0, 3) } finally { $fs.Dispose() } $hadBom = ($read -eq 3 -and $head[0] -eq 0xEF -and $head[1] -eq 0xBB -and $head[2] -eq 0xBF) } } catch { $hadBom = $false } $enc = [System.Text.UTF8Encoding]::new($hadBom) $tmp = "$Path.tstmp" [System.IO.File]::WriteAllText($tmp, $Json, $enc) try { if (Test-Path -LiteralPath $Path) { # backupFileName = [NullString]::Value: replace without keeping a # copy. A bare $null is coerced by PowerShell to '' here, which makes # Replace throw "path is empty" on PS7 and silently fall back to the # non-atomic in-place write below; [NullString]::Value passes a true # null so the atomic same-volume rename actually runs. [System.IO.File]::Replace($tmp, $Path, [NullString]::Value) } else { [System.IO.File]::Move($tmp, $Path) } } catch { # Best-effort fallback (cross-volume temp, Replace unsupported, etc.). # The cleanup is in a finally because the fallback can throw too -- a # read-only or locked settings.json fails BOTH the Replace and the # WriteAllText, and the exception then escaped past the cleanup line, # leaving a .tstmp beside the user's settings.json on every attempt. try { [System.IO.File]::WriteAllText($Path, $Json, $enc) } finally { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } } } } function Write-SettingsFile { param([string]$Path, $Settings) # Depth 100 (the JSON max) rather than 32: a deeply-nested user settings.json # over depth 32 is silently stringified (corrupted) by ConvertTo-Json -- # without warning on Windows PowerShell 5.1. $json = $Settings | ConvertTo-Json -Depth 100 Write-SettingsAtomic -Path $Path -Json $json } |