terminals.ps1
|
# terminals.ps1 -- terminal backend detection and capability model. # # TerminalStyles began as a Windows Terminal tool: a style was applied by # merging theme.json into WT's settings.json. Every other terminal describes # itself differently (iTerm2 has Dynamic Profile JSON, Terminal.app has # NSKeyedArchiver plists, the rest have plain-text configs), and none of them # support the full WT field set. This file is the seam between "what a style # asks for" and "what the host terminal can actually do". # # Two orthogonal axes: # # Detection -- which terminal is hosting THIS session (Get-TerminalKind). # Capability -- which style fields that terminal can honour # (Get-TerminalCapability), so callers degrade gracefully # instead of writing settings nothing will read. # # Dot-sourced by tstyles.ps1. Kept separate because the WT-specific merge logic # in tstyles.ps1 is already long, and because the adapters are the part most # likely to grow (one block per terminal). # Every capability a style can ask for. A terminal's capability record is a # hashtable over exactly these keys, so a typo surfaces as a missing key rather # than a silently-false feature test. $script:TStylesCapabilityNames = @( 'OscPalette', # OSC 4/10/11/12 dynamic colors -- live retint, no config write # Can THIS MODULE write a config the terminal reads on startup? True only # where a writer exists: Windows Terminal's settings.json and Terminal.app's # .terminal profile. Not to be confused with a style surviving a new tab, # which works on every terminal and owes nothing to this flag -- that is # current-style.json plus the OSC re-emit in the startup block, and it # carries colors only. Reading Persist as "styles stick here" is what led # to five terminals claiming fonts and background images that no code ever # delivered. 'Persist', 'Font', # font family + size 'Opacity', # window transparency 'CursorShape', # block / bar / underscore / filled 'BackgroundImage', # a wallpaper behind the text 'TabTitle', # per-profile tab title 'TabColor', # per-profile tab accent color 'Padding' # interior window padding ) function Get-TerminalKind { # Identify the terminal emulator hosting this session. # # Detection is env-var based and ordered most-specific first, because the # generic markers overlap: WezTerm and iTerm2 both set TERM_PROGRAM, and a # multiplexer or SSH session can inherit a stale value from wherever it was # launched. Returns 'Unknown' rather than guessing -- callers fall back to # the OSC path, which is the one thing that works nearly everywhere. # # -EnvTable is a test seam: a hashtable standing in for $env:. Real callers # omit it and the live environment is read. param([hashtable]$EnvTable) $get = { param([string]$Name) if ($null -ne $EnvTable) { return $EnvTable[$Name] } return [System.Environment]::GetEnvironmentVariable($Name) } # Windows Terminal sets WT_SESSION on every tab it hosts. Checked first # because WT also runs on the same machine as VS Code's terminal, which # would otherwise match on TERM_PROGRAM below. if (& $get 'WT_SESSION') { return 'WindowsTerminal' } # kitty and Alacritty set no TERM_PROGRAM, only their own markers. if (& $get 'KITTY_WINDOW_ID') { return 'Kitty' } if (& $get 'ALACRITTY_WINDOW_ID'){ return 'Alacritty' } if (& $get 'GHOSTTY_RESOURCES_DIR') { return 'Ghostty' } # ITERM_SESSION_ID is set even when TERM_PROGRAM has been clobbered by a # multiplexer, so it is the more reliable iTerm2 signal of the two. if (& $get 'ITERM_SESSION_ID') { return 'ITerm2' } switch ((& $get 'TERM_PROGRAM')) { 'Apple_Terminal' { return 'AppleTerminal' } 'iTerm.app' { return 'ITerm2' } 'WezTerm' { return 'WezTerm' } 'ghostty' { return 'Ghostty' } 'vscode' { return 'VSCode' } } return 'Unknown' } function Get-TerminalCapability { # The capability record for a terminal kind: a hashtable keyed by # $script:TStylesCapabilityNames, every value a [bool]. # # These are deliberately conservative. A capability marked $false means # "TerminalStyles will not try", which degrades to a plainer theme; marking # something $true that the terminal ignores is worse, because the user is # told a setting was applied and sees nothing change. param([string]$Kind = (Get-TerminalKind)) # Baseline: nothing. Each block below turns on only what it can prove. $caps = @{} foreach ($n in $script:TStylesCapabilityNames) { $caps[$n] = $false } switch ($Kind) { 'WindowsTerminal' { # The original target, and still the only one that honours the whole # theme.json field set. foreach ($n in $script:TStylesCapabilityNames) { $caps[$n] = $true } } 'ITerm2' { # iTerm2 can do every one of these, through a Dynamic Profile # dropped in ~/Library/Application Support/iTerm2/DynamicProfiles/, # which it picks up live with no restart. Nothing in this module # writes one yet, and a capability is a promise about what # TerminalStyles will deliver -- not about what the terminal could # do in principle. # # Claiming them cost more than leaving them off: BackgroundImage in # particular meant a style that ships a GIF reported success, # painted nothing (the apply path only builds a profile when the # kind is AppleTerminal), skipped the "can't show: background image" # notice that explains a plain result, and still had the picker # prefetch megabytes of GIFs that could never be drawn. $caps.OscPalette = $true $caps.TabTitle = $true } 'AppleTerminal' { # Persistence goes through a .terminal profile plist. No per-profile # tab accent color. # # BackgroundImage is $true, but it is the one capability here that # cannot be delivered to the CURRENT window: there is no escape # sequence for an image, so it can only arrive as part of a profile, # which means a new window. New-AppleTerminalProfile builds that # profile; Apply-StyleNonWT stages it and tells the user how to open # it rather than seizing the screen on every apply. # # OscPalette is $true on the strength of a round-trip probe against # Terminal.app 470 (macOS 26): OSC 4/10/11/12 all answered their # query form, setting OSC 11 to #ff00ff read back as ff00/0000/ff00, # and OSC 111 / OSC 104 restored the profile defaults exactly. So the # picker's live preview and its Esc revert both work here with the # same escape packets Windows Terminal uses -- no AppleScript needed # on the hot path. # Font / Opacity / CursorShape are deliberately NOT claimed. The # profile this module builds carries colors and a background image # and nothing else (see Get-AppleTerminalProfileData), so a style's # font and opacity are dropped on the way through. Terminal.app # would honour them in a profile; until the profile carries them, # saying so here would suppress the "can't show" notice and leave # the user comparing an unchanged font against the screenshot. $caps.OscPalette = $true $caps.Persist = $true $caps.TabTitle = $true $caps.BackgroundImage = $true } # Ghostty / WezTerm / kitty / Alacritty all keep their settings in a # config file this module has never learned to write -- ghostty's # `config`, `wezterm.lua`, `kitty.conf`, `alacritty.toml`. Each of them # can do fonts and opacity, and WezTerm does animated background # images, but none of that reaches the user through TerminalStyles # today. What genuinely works on all four is the OSC retint, which is # the whole live-preview path, so that is what is claimed. # # Adding a writer for any of these is the moment to turn its flags back # on -- one terminal at a time, next to the code that delivers it. 'Ghostty' { $caps.OscPalette = $true } 'WezTerm' { $caps.OscPalette = $true } 'Kitty' { $caps.OscPalette = $true } 'Alacritty' { $caps.OscPalette = $true } 'VSCode' { # The integrated terminal honours OSC colors for the session but its # settings live in VS Code's own settings.json, which is not ours to # rewrite. Live-only. $caps.OscPalette = $true } default { # Unknown terminal: assume only the lowest common denominator. Nearly # every emulator written in the last two decades handles OSC 4/10/11, # and getting it wrong costs one stray escape sequence, not a # corrupted config file. $caps.OscPalette = $true } } return $caps } function Test-TerminalCapability { # Convenience predicate: does $Kind support $Capability? # Throws on an unknown capability name so a typo fails loudly at the call # site instead of quietly reading as "unsupported". param( [Parameter(Mandatory)][string]$Capability, [string]$Kind = (Get-TerminalKind) ) if ($script:TStylesCapabilityNames -notcontains $Capability) { throw "Unknown terminal capability '$Capability'. Known: $($script:TStylesCapabilityNames -join ', ')" } return [bool](Get-TerminalCapability -Kind $Kind)[$Capability] } function Get-TerminalDisplayName { # Human-readable name for messages ("Ghostty", not "Ghostty" == kind by # accident). Kept as an explicit map so renaming a kind doesn't silently # change user-facing output. param([string]$Kind = (Get-TerminalKind)) switch ($Kind) { 'WindowsTerminal' { 'Windows Terminal' } 'AppleTerminal' { 'Terminal.app' } 'ITerm2' { 'iTerm2' } 'Ghostty' { 'Ghostty' } 'WezTerm' { 'WezTerm' } 'Kitty' { 'kitty' } 'Alacritty' { 'Alacritty' } 'VSCode' { 'VS Code terminal' } default { 'this terminal' } } } function Test-HostOutputVisible { # True when this session's stdout is a terminal a human is looking at. # # Guards everything the module prints at load time -- the OSC palette and # the style's banner. `pwsh -c '...' > out.txt` from a $PROFILE that imports # TerminalStyles would otherwise prepend a banner and ~280 bytes of escape # sequences to out.txt, quietly corrupting the output of any script that # captures it. Redirected output has no terminal listening, so suppressing # is free. try { return -not [Console]::IsOutputRedirected } catch { # No console object at all (hosted runspace, some CI harnesses). return $false } } function Test-StyledHost { # True when the host terminal can actually render a style, and therefore # when it makes sense to load the style's prompt/banner at startup. # # Replaces the old Test-InWindowsTerminal gate. The original reasoning was # "only WT renders the colors, so only load the themed prompt there" -- that # reasoning is right, but WT is no longer the only terminal that qualifies. # Any terminal that can take an OSC palette, or that we can write a config # for, will render the style; a bare pipe or a dumb host will not. param([string]$Kind = (Get-TerminalKind)) $caps = Get-TerminalCapability -Kind $Kind return [bool]($caps.OscPalette -or $caps.Persist) } function Get-CurrentStyleRecordPath { # Where the "which style is applied" record lives off Windows. # # On Windows Terminal the applied style is recoverable by reading the # colorScheme name back out of settings.json, so no separate record is kept. # Terminals driven by OSC have no such readback -- the escape sequences are # fire-and-forget -- so the choice is recorded here instead. This is also # what the startup re-emit reads to restore colors in a new tab. Join-Path $script:TStylesDataRoot 'current-style.json' } function Set-CurrentStyleRecord { # Record the applied style. Best-effort: a failure to write the record makes # `tstyles current` and the startup re-emit forget the choice, but it must # never take down an apply that already succeeded. param( [Parameter(Mandatory)][string]$StyleName, [string]$Kind = (Get-TerminalKind) ) try { $record = [pscustomobject]@{ name = $StyleName terminal = $Kind appliedAt = (Get-Date).ToString('o', [System.Globalization.CultureInfo]::InvariantCulture) } $json = $record | ConvertTo-Json -Depth 5 [System.IO.File]::WriteAllText((Get-CurrentStyleRecordPath), $json, [System.Text.UTF8Encoding]::new($false)) } catch { } } function Get-CurrentStyleRecord { # The recorded style, or $null when there is none / the file is unreadable # or corrupt. Corruption self-heals: the next apply overwrites it. $path = Get-CurrentStyleRecordPath if (-not (Test-Path -LiteralPath $path)) { return $null } try { $json = [System.IO.File]::ReadAllText($path, [System.Text.UTF8Encoding]::new($false)) if (-not $json.Trim()) { return $null } $record = $json | ConvertFrom-Json if (-not $record.name) { return $null } return $record } catch { return $null } } function Clear-CurrentStyleRecord { # Forget the applied style (used by `tstyles reset`). -Force so the write # survives a read-only attribute; missing file is not an error. $path = Get-CurrentStyleRecordPath if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } } function Write-HostOscPacket { # Emit raw escape bytes to the terminal. # # [Console]::Out.Write, NOT Write-Host: Write-Host routes through the # PowerShell host's formatting layer, which can swallow or re-encode control # characters (and in a transcript or a redirected stream would write the # escapes as visible text). Console.Out goes straight at stdout, which is # where the terminal is listening. Flush so the repaint happens now rather # than whenever the buffer next drains -- the picker depends on that # immediacy for per-keystroke preview. # Returns $true when the bytes actually reached a terminal, $false when # there was none to reach. Callers MUST NOT assume success: an apply that # silently painted nothing and still reported "Style applied" is exactly the # confusion this return value exists to prevent. param([Parameter(Mandatory)][AllowEmptyString()][string]$Packet) if (-not $Packet) { return $false } # Never write escape bytes into a redirected stream. The module re-emits the # applied style's palette when it loads, and a $PROFILE that imports it turns # every `pwsh -c ... > out.txt` and every piped invocation into a file with # ~280 bytes of OSC glued to the front of the real output. Redirected stdout # means nothing is listening for escape sequences, so there is nothing to # lose by staying quiet. (The shell loader makes the same check via $-.) try { if ([Console]::IsOutputRedirected) { return $false } } catch { # No console at all (a runspace, a hosted app): also nothing to paint. return $false } try { [Console]::Out.Write($Packet) [Console]::Out.Flush() return $true } catch { # A redirected/absent console (CI, a job runspace) has nothing to paint. # Silent by design: colors are cosmetic, and throwing here would abort an # otherwise-successful apply. return $false } } function Invoke-TerminalStyleOscApply { # Retint the live terminal to $Scheme via OSC. Returns $true when the packet # was emitted, $false when the terminal cannot take one. param( [Parameter(Mandatory)]$Scheme, [string]$Kind = (Get-TerminalKind) ) if (-not (Get-TerminalCapability -Kind $Kind).OscPalette) { return $false } # Return what actually happened, not what the terminal is capable of. The # two differ whenever stdout is redirected -- a pipe, a file, an agent shell # -- and reporting capability there made `tstyles <name>` claim success # while changing nothing on screen. return (Write-HostOscPacket -Packet (Get-SchemeOscPacket -Scheme $Scheme)) } function Invoke-TerminalStyleOscReset { # Hand color control back to the terminal's own configured scheme. param([string]$Kind = (Get-TerminalKind)) if (-not (Get-TerminalCapability -Kind $Kind).OscPalette) { return $false } return (Write-HostOscPacket -Packet (Get-OscResetPacket)) } # === zsh / bash support ==================================================== # # TerminalStyles is a PowerShell module, but a Mac user's login shell is # usually zsh. The colors are a property of the terminal, not the shell, so # they apply either way -- but the prompt and banner would not, and a zsh user # would get a half-applied style. # # The shell side therefore reads only precomputed files. It never starts # pwsh: the loader runs on every interactive shell start, and paying # PowerShell's startup cost there would be felt on every new tab. function Get-ShellRuntimePath { # Staged copy of shell/tstyles.sh under the data root. # # The loader block in ~/.zshrc points here rather than at the module # directory, because a PSResourceGet upgrade installs to a NEW versioned # directory -- a path baked into ~/.zshrc at install time would dangle after # the first update. The data root is stable across versions. Join-Path $script:TStylesDataRoot 'tstyles.sh' } function Get-ShellPromptPath { Join-Path $script:TStylesDataRoot 'current-prompt.sh' } function Get-ShellOscPath { Join-Path $script:TStylesDataRoot 'current-style.osc' } function Get-ShellCliPath { Join-Path $script:TStylesDataRoot 'tstyles-cli.ps1' } function Sync-ShellRuntime { # Refresh the staged runtime from the module. Runs on every apply so an # upgraded module's runtime replaces the staged copy without the user having # to re-run shell-init. $src = Join-Path (Join-Path $script:TStylesModuleRoot 'shell') 'tstyles.sh' if (-not (Test-Path -LiteralPath $src)) { return $false } try { Copy-Item -LiteralPath $src -Destination (Get-ShellRuntimePath) -Force # Entry point for the `tstyles` shell function. Generated rather than # shipped so the module root is baked in at stage time: a zsh user has # no PSModulePath set up, and `Import-Module TerminalStyles` by name # would fail for a bootstrap install. Regenerated on every apply, so an # upgrade to a new versioned directory refreshes the path. $moduleManifest = Join-Path $script:TStylesModuleRoot 'TerminalStyles.psd1' # Doubled for the single-quoted string it is interpolated into. An # apostrophe in the path -- ~/Documents/O'Brien/... is an ordinary macOS # home -- otherwise closed the quote early and produced a shim that # could not parse, so every `tstyles` call from zsh died on a syntax # error while Sync-ShellRuntime reported success. $manifestLiteral = $moduleManifest.Replace("'", "''") $cli = @" # Generated by TerminalStyles -- do not edit; rewritten on every style apply. # Entry point for the ``tstyles`` shell function in tstyles.sh. `$ErrorActionPreference = 'Stop' # Load the library WITHOUT its shell-startup behaviour. A normal import # re-emits the CURRENTLY applied style's palette and dot-sources its # profile.ps1 -- so ``tstyles list`` from zsh repainted the terminal and printed # the old style's banner before it listed anything. `$global:TStylesNoAutoLoad = `$true Import-Module '$manifestLiteral' -DisableNameChecking Invoke-TerminalStyle @args "@ [System.IO.File]::WriteAllText((Get-ShellCliPath), $cli, [System.Text.UTF8Encoding]::new($false)) return $true } catch { return $false } } function Set-ShellStyleState { # Stage everything the zsh/bash loader needs for $StyleName: # current-style.osc -- the exact escape packet, so a new tab restores the # palette with one `cat` and no computation # current-prompt.sh -- the style's prompt/banner, if it ships one # # Best-effort throughout: a PowerShell user with no shell integration set up # should never see an apply fail because these could not be written. param( [Parameter(Mandatory)][string]$StyleName, [Parameter(Mandatory)][string]$StyleDir, [Parameter(Mandatory)]$Scheme, [switch]$KeepPrompt ) try { $enc = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText((Get-ShellOscPath), (Get-SchemeOscPacket -Scheme $Scheme), $enc) $promptSrc = Join-Path $StyleDir 'prompt.sh' $promptDst = Get-ShellPromptPath if (-not $KeepPrompt -and (Test-Path -LiteralPath $promptSrc)) { Copy-Item -LiteralPath $promptSrc -Destination $promptDst -Force } elseif (Test-Path -LiteralPath $promptDst) { # -KeepPrompt, or a style with no shell prompt: drop the previous # style's, or the old prompt would outlive the style that installed it. Remove-Item -LiteralPath $promptDst -Force -ErrorAction SilentlyContinue } [void](Sync-ShellRuntime) } catch { } } function Clear-ShellStyleState { # Inverse of Set-ShellStyleState. The staged runtime (tstyles.sh) stays -- # it is the loader's target, and removing it would break the block in # ~/.zshrc rather than just unstyling the shell. foreach ($p in @((Get-ShellOscPath), (Get-ShellPromptPath))) { if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } } } function Get-ShellLoaderBlock { # The block written into ~/.zshrc / ~/.bashrc. Marker comments match the # PowerShell loader's so uninstall can strip both with one pattern. # # Guarded on the runtime existing: if the user removes TerminalStyles # without running uninstall, the block does nothing instead of printing # "no such file" on every shell start. # # An `if` rather than `[ -r ... ] && . ...`. The && form is silent but it is # NOT a no-op: when the runtime is gone the compound command is false, and # since this block is the last thing in the rc file, the whole file exits 1. # `set -e; source ~/.bashrc` then aborts before doing any work, # `source ~/.bashrc && next-step` skips next-step, and every new terminal # opens with a failed-status indicator and no failing command to explain it. # Verified: healthy 0, orphaned 1, both shells. The `if` form exits 0 either # way. # SINGLE-quoted, not double. Double quotes protect spaces and apostrophes # but not $, ` or \ -- and a home directory containing a '$' is perfectly # legal. With `"..."` the shell expanded it, the path came out wrong, and # the runtime silently never loaded: no colours, no prompt, no error, on # every shell forever. Single quotes make every byte literal; an embedded # apostrophe is closed, escaped and reopened, which is the only character # single quotes cannot carry. $runtime = (Get-ShellRuntimePath).Replace("'", "'\''") return @" # ===== TerminalStyles BEGIN ===== if [ -r '$runtime' ]; then . '$runtime'; fi # ===== TerminalStyles END ===== "@ } function Get-ShellRcCandidate { # The rc files worth registering in, with the shell each belongs to. # ~/.bash_profile is included because macOS Terminal.app starts bash as a # LOGIN shell, which reads .bash_profile and never .bashrc. param( # Test seam: real callers omit it and the live $HOME is used. [string]$HomeDir = $HOME, # The zsh config directory, normally $env:ZDOTDIR. Its own seam, # because -HomeDir alone could not sandbox it -- see below. [string]$ZDotDir ) # -HomeDir means a SANDBOX, and the ambient $env:ZDOTDIR is not part of it. # # This read used to be $env:ZDOTDIR directly, which made -HomeDir a seam # that leaked: three of the four candidates honoured it and the fourth # reached straight past it into the caller's real environment. The test # suite paid for that. tests/Uninstall-ReversesShellInit.Tests.ps1 calls # shell-init with -HomeDir pointed at a TestDrive, and on any machine with # ZDOTDIR set -- the standard XDG zsh layout, and every dotfile framework # that relocates zsh config, which is exactly the setup this candidate was # ADDED for -- running the suite appended a loader block to the developer's # own zsh config, pointing at a Pester temp path that is deleted when the # run ends. Nothing removed it; the run reported PASS=27 FAIL=0. # # So a caller that sandboxes the home and says nothing about the zsh config # dir gets no ZDOTDIR candidate at all. A caller that wants one names it. # Callers that sandbox neither -- `tstyles shell-init`, and uninstall's # bare call -- still get the live environment, so the feature is unchanged # for every real user. if (-not $PSBoundParameters.ContainsKey('ZDotDir')) { $ZDotDir = if ($PSBoundParameters.ContainsKey('HomeDir')) { $null } else { $env:ZDOTDIR } } # $ZDOTDIR first when it is set: zsh reads $ZDOTDIR/.zshrc and does NOT read # ~/.zshrc, so for anyone with a relocated config (the standard XDG layout, # and every dotfile framework that uses one) the block went into a file zsh # never opens. shell-init reported success and no shell ever loaded it. $zdot = @() if ($ZDotDir -and (Test-Path -LiteralPath $ZDotDir)) { $zdotRc = Join-Path $ZDotDir '.zshrc' if ($zdotRc -ne (Join-Path $HomeDir '.zshrc')) { $zdot = @([pscustomobject]@{ Shell = 'zsh'; Path = $zdotRc }) } } $zdot @( [pscustomobject]@{ Shell = 'zsh'; Path = (Join-Path $HomeDir '.zshrc') } [pscustomobject]@{ Shell = 'bash'; Path = (Join-Path $HomeDir '.bashrc') } [pscustomobject]@{ Shell = 'bash'; Path = (Join-Path $HomeDir '.bash_profile') } ) } function Get-RcFileEncoding { <# .SYNOPSIS The encoding to read and write a user's rc file with. .DESCRIPTION ISO-8859-1, which maps every byte 0-255 to exactly one character and back. Reading and writing an rc file through it is byte-preserving whatever the file really is, and the markers and loader block this module cares about are pure ASCII either way. It used to be UTF-8. Both halves read the WHOLE file and write the WHOLE file back, so a single byte that is not valid UTF-8 -- a latin-1 comment, a stray byte from an old editor -- was decoded to U+FFFD on the first shell-init and written back as the replacement character. The user's own content, silently and permanently corrupted, by a tool that was only asked to append three lines. #> return [System.Text.Encoding]::GetEncoding(28591) } function Save-FirstTouchBackup { <# .SYNOPSIS Back up a user's own config file the FIRST time TerminalStyles edits it. .DESCRIPTION Every rc file and $PROFILE this project writes is a file the USER owns and that predates us. install.ps1 has backed one up since it was written -- `<path>.bak-<timestamp>`, once, skipped when the loader block is already there, pinned by tests/Install-Hardening.Tests.ps1 -- and the module half never did. So `tstyles shell-init` and `tstyles register` rewrote a hand-maintained .zshrc or profile.ps1 with no copy kept anywhere, and `shell-remove` rewrote it again on the way out. FIRST TOUCH is the whole rule, and the reason is that a backup taken later is worth less: once our block is in the file, a fresh copy would capture a file that already carries it. The pristine version -- the one the user would actually want back -- exists only before we first write. That is also why the block being present means SKIP rather than "back up again". Returns the backup path when one was written, else $null. Never throws: a failed backup must not stop the operation, which is itself recoverable. NOTE the guard order. `$Content -match ''` is TRUE for every string, so a bare `-match $BlockPattern` with an empty pattern would report "already ours" and silently skip the backup on every call -- the exact opposite of the intent. The `$BlockPattern -and` is load-bearing. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Content, # Matches when the file already carries our block. Empty/absent means # "cannot tell", which errs toward taking the backup. [AllowEmptyString()][string]$BlockPattern ) if ($BlockPattern -and $Content -match $BlockPattern) { return $null } if (-not (Test-Path -LiteralPath $Path)) { return $null } try { $bak = "$Path.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" Copy-Item -LiteralPath $Path -Destination $bak -Force -ErrorAction Stop return $bak } catch { return $null } } function Register-ShellLoader { # Add (or refresh) the loader block in one rc file. Returns the action taken # so the caller can report it: 'added', 'updated', 'unchanged', 'skipped', # or 'failed' when the file could not be written. # # 'failed' rather than an exception: shell-init registers into several rc # files in a loop, and one unwritable file (read-only, owned by root, on a # full disk) used to abort the whole command with a raw .NET # MethodInvocationException -- after some files had already been written and # before anything was reported, so the user saw a stack trace and had no # idea which of their rc files had been touched. # # Only touches a file that already exists, unless -Create is passed: silently # creating ~/.bashrc on a machine that only uses zsh would be a surprise. param( [Parameter(Mandatory)][string]$Path, [switch]$Create, [switch]$Force ) $begin = '# ===== TerminalStyles BEGIN =====' $end = '# ===== TerminalStyles END =====' $block = Get-ShellLoaderBlock $enc = Get-RcFileEncoding $exists = Test-Path -LiteralPath $Path if (-not $exists -and -not $Create) { return 'skipped' } $content = if ($exists) { [System.IO.File]::ReadAllText($Path, $enc) } else { '' } if ($content -match [regex]::Escape($begin)) { if (-not $Force) { # Already registered. Compare the body so an upgraded data root or # runtime path is picked up without -Force. $pattern = [regex]::Escape($begin) + '.*?' + [regex]::Escape($end) $existing = [regex]::Match($content, $pattern, 'Singleline').Value if ($existing.Trim() -eq $block.Trim()) { return 'unchanged' } } $pattern = [regex]::Escape($begin) + '.*?' + [regex]::Escape($end) $updated = [regex]::Replace($content, $pattern, $block.Trim(), 'Singleline') try { [System.IO.File]::WriteAllText($Path, $updated, $enc) } catch { return 'failed' } return 'updated' } # Append. A newline guard keeps the block from landing on the same line as # whatever the user's rc file ended with. # One newline before the block, and Unregister-ShellLoader substitutes # exactly one back. It used to append a blank line as well and give only one # back, so every init/remove cycle grew the file by a line -- shell-remove # was not the byte-exact reversal it is documented to be. $sep = if ($content -and -not $content.EndsWith("`n")) { "`n" } else { '' } # First touch: this is the one moment the user's pristine file still exists. # The refresh path above does not back up, and should not -- the block is # already there, so a copy would capture a file that carries it. $bak = Save-FirstTouchBackup -Path $Path -Content $content -BlockPattern ([regex]::Escape($begin)) try { [System.IO.File]::WriteAllText($Path, $content + $sep + $block.Trim() + "`n", $enc) } catch { return 'failed' } if ($bak) { Write-Host " backed up your original to: $bak" -ForegroundColor DarkGray } return 'added' } function Unregister-ShellLoader { <# .SYNOPSIS Strip the loader block from one rc file. .OUTPUTS 'removed' -- the block was there and is gone 'none' -- no block in this file (or the file does not exist) 'malformed' -- a BEGIN marker with no matching END; nothing was removed 'failed' -- the block is there and the file could not be written A STATUS, not a boolean, because the three failure modes are not the same thing and were being reported as one. Two of them lied to the user: * A BEGIN with no END (hand-edited rc, interrupted write) made the Replace match nothing. The unchanged text was written back and $true returned, so shell-remove said the loader was removed and a new tab would restore the prompt -- while every new shell still sourced the runtime. * An unwritable rc file (read-only dotfiles, a symlink into a nix or chezmoi store) returned the same $false as "there was no block here", and the caller turned that into "No shell loader was registered." -- telling the user the opposite of the truth and sending them looking for a block the tool had just denied existed. Callers must compare explicitly. `if (Unregister-ShellLoader ...)` is true for EVERY status now, including 'none'. #> param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return 'none' } $begin = '# ===== TerminalStyles BEGIN =====' $end = '# ===== TerminalStyles END =====' $enc = Get-RcFileEncoding $content = [System.IO.File]::ReadAllText($Path, $enc) if ($content -notmatch [regex]::Escape($begin)) { return 'none' } $pattern = '\r?\n?' + [regex]::Escape($begin) + '.*?' + [regex]::Escape($end) + '\r?\n?' $stripped = [regex]::Replace($content, $pattern, "`n", 'Singleline') if ($stripped -eq $content) { return 'malformed' } # Same reasoning as Register-ShellLoader: one unwritable rc file must not # take down a shell-remove that has already stripped others. try { [System.IO.File]::WriteAllText($Path, $stripped, $enc) } catch { return 'failed' } return 'removed' } function Invoke-TerminalStylesShellInit { # `tstyles shell-init` -- register the loader in the user's zsh/bash rc # files so a non-PowerShell shell picks up the applied style too. # `tstyles shell-remove` (-Remove) takes it back out. # # Idempotent: re-running refreshes a stale block rather than appending a # second one. -Force rewrites even a block that already matches. [CmdletBinding()] param( [switch]$Force, [switch]$Remove, # Test seam: real callers omit it and the live $HOME is used. [string]$HomeDir = $HOME, # The zsh config dir. Omitted by real callers, who get $env:ZDOTDIR. [string]$ZDotDir ) # Forwarded by what the caller actually BOUND, not by value. All three # cases -- bare, sandboxed, and explicitly named -- are then decided in one # place, Get-ShellRcCandidate, where each is directly testable. Resolving # here as well would duplicate the rule in a second frame, and only one of # the two could be covered by a test that does not write to the real $HOME. $splat = @{} if ($PSBoundParameters.ContainsKey('HomeDir')) { $splat.HomeDir = $HomeDir } if ($PSBoundParameters.ContainsKey('ZDotDir')) { $splat.ZDotDir = $ZDotDir } $candidates = Get-ShellRcCandidate @splat if ($Remove) { # Each status reported for what it is. A failure and a malformed block # both used to read as "nothing was registered", so the user was told # the loader was gone while every new shell still sourced it. $removed = 0 $problems = @() foreach ($c in $candidates) { switch (Unregister-ShellLoader -Path $c.Path) { 'removed' { Write-Host (" removed from {0}" -f $c.Path) -ForegroundColor Yellow $removed++ } 'malformed' { Write-Host (" ! {0} has a TerminalStyles BEGIN marker with no matching END." -f $c.Path) -ForegroundColor Red Write-Host " Nothing was removed. Delete the block by hand -- it still loads on every shell." -ForegroundColor Red $problems += $c.Path } 'failed' { Write-Host (" ! could not write {0}" -f $c.Path) -ForegroundColor Red Write-Host " The loader is still there. Check the file's permissions (a read-only" -ForegroundColor Red Write-Host " dotfile, or a symlink into a managed store) and re-run." -ForegroundColor Red $problems += $c.Path } } } Clear-ShellStyleState if ($problems.Count -gt 0) { Write-Host "" Write-Host " Shell loader NOT fully removed. See the file(s) above." -ForegroundColor Yellow } elseif ($removed -eq 0) { Write-Host " No shell loader was registered." -ForegroundColor Gray } else { Write-Host "" Write-Host " Open a new tab to get your original prompt back." } return } if (-not (Sync-ShellRuntime)) { Write-Error "Could not stage the shell runtime (shell/tstyles.sh missing from the module)." return } # Register in every rc file the user actually has. Registering only the # login shell's would miss the common case of someone who uses zsh # interactively but keeps a bash rc for scripts -- and costs nothing. $touched = @() foreach ($c in $candidates) { $action = Register-ShellLoader -Path $c.Path -Force:$Force if ($action -ne 'skipped') { $touched += [pscustomobject]@{ Path = $c.Path; Action = $action } } } # A bash user needs the LOGIN file, and it is the one most likely absent. # macOS Terminal.app starts bash as a login shell, which reads # ~/.bash_profile and never ~/.bashrc -- that is why .bash_profile is in the # candidate list at all. But Register-ShellLoader skips a file that does not # exist, and the -Create fallback below only fired when NOTHING was # registered. So a bash user with a .bashrc and no .bash_profile got a green # "added ~/.bashrc", opened a new Terminal window, and saw nothing: default # prompt, default colours, no banner, and no hint that the file the style # needs was never written. # # Created as a source of .bashrc plus the block, which is the conventional # shape -- a bare .bash_profile would stop bash reading ~/.profile, and # would leave their own .bashrc unloaded in login shells exactly as before. $bashrc = $candidates | Where-Object { $_.Path -like '*.bashrc' } | Select-Object -First 1 $bashProfile = $candidates | Where-Object { $_.Path -like '*.bash_profile' } | Select-Object -First 1 $dotProfile = Join-Path $HomeDir '.profile' if ($bashrc -and $bashProfile -and (Test-Path -LiteralPath $bashrc.Path) -and -not (Test-Path -LiteralPath $bashProfile.Path)) { if (Test-Path -LiteralPath $dotProfile) { # They have a ~/.profile that login bash reads today. Register there # rather than creating a .bash_profile that would shadow it. $action = Register-ShellLoader -Path $dotProfile -Force:$Force if ($action -ne 'skipped') { $touched += [pscustomobject]@{ Path = $dotProfile; Action = $action } } } else { $seed = "# Created by TerminalStyles: bash login shells read this file, never" + [Environment]::NewLine + "# ~/.bashrc. Sourcing it here is the conventional way to get both." + [Environment]::NewLine + '[ -r "$HOME/.bashrc" ] && . "$HOME/.bashrc"' + [Environment]::NewLine try { [System.IO.File]::WriteAllText($bashProfile.Path, $seed, [System.Text.UTF8Encoding]::new($false)) $action = Register-ShellLoader -Path $bashProfile.Path -Force:$Force $touched += [pscustomobject]@{ Path = $bashProfile.Path; Action = $action } Write-Host "" Write-Host (" Created {0}, which is what bash reads for a login shell" -f $bashProfile.Path) -ForegroundColor DarkGray Write-Host " (Terminal.app opens one). It sources your ~/.bashrc." -ForegroundColor DarkGray } catch { Write-Host (" ! could not create {0}: {1}" -f $bashProfile.Path, $_.Exception.Message) -ForegroundColor Yellow } } } # Nothing existed to register in. Create the rc file for the login shell # rather than doing nothing and leaving the user to guess. if (-not $touched) { $loginShell = if ($env:SHELL -and $env:SHELL -match 'zsh') { 'zsh' } else { 'bash' } $target = $candidates | Where-Object Shell -eq $loginShell | Select-Object -First 1 $action = Register-ShellLoader -Path $target.Path -Create $touched += [pscustomobject]@{ Path = $target.Path; Action = $action } # A bash login shell reads .bash_profile, so creating only .bashrc would # have produced the same silent nothing as above. if ($loginShell -eq 'bash' -and $target.Path -like '*.bashrc') { $bp = $candidates | Where-Object { $_.Path -like '*.bash_profile' } | Select-Object -First 1 if ($bp -and -not (Test-Path -LiteralPath $dotProfile)) { $seed = '[ -r "$HOME/.bashrc" ] && . "$HOME/.bashrc"' + [Environment]::NewLine try { [System.IO.File]::WriteAllText($bp.Path, $seed, [System.Text.UTF8Encoding]::new($false)) $touched += [pscustomobject]@{ Path = $bp.Path Action = (Register-ShellLoader -Path $bp.Path -Force:$Force) } } catch { } } } } Write-Host "" foreach ($t in $touched) { $color = switch ($t.Action) { 'added' { 'Green' } 'updated' { 'Cyan' } 'failed' { 'Red' } default { 'Gray' } } Write-Host (" {0,-9} {1}" -f $t.Action, $t.Path) -ForegroundColor $color if ($t.Action -eq 'failed') { Write-Host " (could not write it -- check the file's permissions)" -ForegroundColor DarkGray } } Write-Host "" # Name the file that was actually registered. This was hardcoded to # ~/.zshrc and printed unconditionally, so a bash user who had just had # .bashrc and .bash_profile written was told to source a zsh file they may # not even have. $hintPath = ($touched | Where-Object { $_.Action -ne 'failed' } | Select-Object -First 1).Path if ($hintPath) { $shown = if ($hintPath.StartsWith($HomeDir)) { '~' + $hintPath.Substring($HomeDir.Length) } else { $hintPath } Write-Host (" Open a new tab, or run: source {0}" -f $shown) -ForegroundColor DarkGray } else { Write-Host " Open a new tab to pick it up." -ForegroundColor DarkGray } Write-Host "" } # === Terminal.app profiles (colors + background image) ===================== # # OSC escape sequences cover colors, but there is no escape sequence for a # background image -- an image can only reach Terminal.app through a profile. # So a style that ships one is applied by writing a .terminal profile and # opening it, which gives a new window carrying the whole style. # # The profile format is unforgiving: colors are NSKeyedArchiver archives of # NSColor, and the image is an archive of an NSMutableData holding a # security-scoped bookmark. Get any of that wrong and Terminal rejects the file # wholesale as "corrupt", naming no key. shell/appleterminal.js builds those # blobs; this half assembles them into a plist. # scheme.json field -> Terminal.app profile key. Terminal names the magenta slot # "Magenta" where a Windows Terminal scheme calls it "purple". $script:TStylesAppleColorMap = [ordered]@{ background = 'BackgroundColor' foreground = 'TextColor' cursorColor = 'CursorColor' selectionBackground = 'SelectionColor' black = 'ANSIBlackColor' red = 'ANSIRedColor' green = 'ANSIGreenColor' yellow = 'ANSIYellowColor' blue = 'ANSIBlueColor' purple = 'ANSIMagentaColor' cyan = 'ANSICyanColor' white = 'ANSIWhiteColor' brightBlack = 'ANSIBrightBlackColor' brightRed = 'ANSIBrightRedColor' brightGreen = 'ANSIBrightGreenColor' brightYellow = 'ANSIBrightYellowColor' brightBlue = 'ANSIBrightBlueColor' brightPurple = 'ANSIBrightMagentaColor' brightCyan = 'ANSIBrightCyanColor' brightWhite = 'ANSIBrightWhiteColor' } function Get-AppleTerminalProfileData { # Run the JXA helper over a scheme (+ optional image) and return a hashtable # of Terminal profile key -> base64 archive. Returns $null when the helper # is missing or fails; callers fall back to the OSC-only path. param( [Parameter(Mandatory)]$Scheme, [string]$BackgroundImage ) $helper = Join-Path (Join-Path $script:TStylesModuleRoot 'shell') 'appleterminal.js' if (-not (Test-Path -LiteralPath $helper)) { return $null } $colors = [ordered]@{} foreach ($field in $script:TStylesAppleColorMap.Keys) { $hex = $Scheme.$field if ($hex) { $colors[$script:TStylesAppleColorMap[$field]] = [string]$hex } } $tmpRoot = [System.IO.Path]::GetTempPath() $runId = [guid]::NewGuid().Guid.Substring(0, 8) $specPath = Join-Path $tmpRoot "tstyles-spec-$runId.json" $outPath = Join-Path $tmpRoot "tstyles-out-$runId.json" $enc = [System.Text.UTF8Encoding]::new($false) try { $spec = [pscustomobject]@{ colors = $colors image = if ($BackgroundImage) { $BackgroundImage } else { '' } } [System.IO.File]::WriteAllText($specPath, ($spec | ConvertTo-Json -Depth 5), $enc) & osascript -l JavaScript $helper $specPath $outPath *> $null if (-not (Test-Path -LiteralPath $outPath)) { return $null } $json = [System.IO.File]::ReadAllText($outPath, $enc) if (-not $json.Trim()) { return $null } $result = @{} foreach ($p in ($json | ConvertFrom-Json).PSObject.Properties) { $result[$p.Name] = [string]$p.Value } if ($result.Count -eq 0) { return $null } return $result } catch { return $null } finally { foreach ($f in @($specPath, $outPath)) { if (Test-Path -LiteralPath $f) { Remove-Item -LiteralPath $f -Force -ErrorAction SilentlyContinue } } } } function ConvertTo-AppleTerminalBackground { # Terminal.app renders a still image but NOT an animated GIF: a profile # pointing at one gets a blank background, with no error anywhere. Every # bundled background in this project is a GIF, so without this the feature # appears to do nothing at all -- which is exactly how it was first reported. # # Converts a GIF to a static PNG (sips takes the first frame) and caches the # result beside the original. Anything already static is returned unchanged. # On failure the original path is returned: a background that silently does # not render is no worse than the state before, and is not worth failing an # apply over. param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $Path } if ([System.IO.Path]::GetExtension($Path).ToLowerInvariant() -ne '.gif') { return $Path } $still = [System.IO.Path]::ChangeExtension($Path, '.still.png') # Reuse a previous conversion unless the source has since changed. if (Test-Path -LiteralPath $still) { try { if ((Get-Item -LiteralPath $still).LastWriteTimeUtc -ge (Get-Item -LiteralPath $Path).LastWriteTimeUtc) { return $still } } catch { } } if (-not (Get-Command sips -ErrorAction SilentlyContinue)) { return $Path } try { & sips -s format png $Path --out $still *> $null if ((Test-Path -LiteralPath $still) -and (Get-Item -LiteralPath $still).Length -gt 0) { return $still } } catch { } return $Path } function New-AppleTerminalProfile { # Write a .terminal profile for $StyleName and return its path. # # Emitted as an XML plist rather than binary: the values that must be binary # are already base64 <data>, and XML keeps the file inspectable when # something goes wrong -- which, given how silently Terminal rejects a bad # profile, matters more here than the few hundred bytes it costs. param( [Parameter(Mandatory)][string]$StyleName, [Parameter(Mandatory)]$Scheme, [string]$BackgroundImage, [string]$ProfileName, [string]$OutPath ) if (-not $ProfileName) { $ProfileName = "TerminalStyles $StyleName" } if (-not $OutPath) { $dir = Join-Path $script:TStylesDataRoot 'profiles' if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $OutPath = Join-Path $dir "$StyleName.terminal" } # Animated GIFs do not render in Terminal.app; hand the profile a still. if ($BackgroundImage) { $BackgroundImage = ConvertTo-AppleTerminalBackground -Path $BackgroundImage } $data = Get-AppleTerminalProfileData -Scheme $Scheme -BackgroundImage $BackgroundImage if (-not $data) { return $null } $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('<?xml version="1.0" encoding="UTF-8"?>') [void]$sb.AppendLine('<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">') [void]$sb.AppendLine('<plist version="1.0">') [void]$sb.AppendLine('<dict>') # type + ProfileCurrentVersion are what mark this as an importable window # setting; without them Terminal opens the file as a document instead. [void]$sb.AppendLine(' <key>name</key>') [void]$sb.AppendLine(" <string>$([System.Security.SecurityElement]::Escape($ProfileName))</string>") [void]$sb.AppendLine(' <key>type</key>') [void]$sb.AppendLine(' <string>Window Settings</string>') [void]$sb.AppendLine(' <key>ProfileCurrentVersion</key>') [void]$sb.AppendLine(' <real>2.0699999999999998</real>') foreach ($key in ($data.Keys | Sort-Object)) { [void]$sb.AppendLine(" <key>$key</key>") [void]$sb.AppendLine(" <data>$($data[$key])</data>") } [void]$sb.AppendLine('</dict>') [void]$sb.AppendLine('</plist>') [System.IO.File]::WriteAllText($OutPath, $sb.ToString(), [System.Text.UTF8Encoding]::new($false)) return $OutPath } |