lib/background.ps1
|
# background.ps1 -- resolving a style's background image, and remembering when # there isn't one. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # Resolution is three tiers plus one inheritance hop: a file bundled beside the # style, the per-user cache, a tuned style's base, then a lazy fetch from the # gifs branch. The fetch downloads to a .part and renames, because a file # sitting at the cache path is treated as a complete entry by every reader and # nothing revalidates it. # # The negative cache is dated, and deliberately so: a 404 means the asset is # genuinely absent and is worth remembering for a month, while an unreachable # network means nothing and is remembered for an hour. function Test-HttpNotFound { # Did this web error mean "the server answered, and the file is not there" # (404) as opposed to "I could not reach the server" (DNS failure, timeout, # offline, proxy, 5xx)? # # The distinction is the whole point of the negative cache: an absent asset # is a stable fact worth remembering, an unreachable network is not. # Both engines surface it the same way -- a 404 carries a .Response with a # StatusCode, a transport failure has no .Response at all (verified on # pwsh 7's HttpResponseException/HttpRequestException and 5.1's WebException, # whose .Response is null for NameResolutionFailure). param($ErrorRecord) try { $response = $ErrorRecord.Exception.Response if (-not $response) { return $false } return ([int]$response.StatusCode -eq 404) } catch { return $false } } function Test-BackgroundProbeSuppressed { # Should we skip the lazy fetch because a previous probe already answered? # Pure, so the expiry rules are testable without touching the network. # # Two lifetimes, because the two answers are worth different amounts: # absent -- every extension 404'd. A stable fact, but not permanent: # the gifs branch is updated independently of releases, so # a style CAN gain an asset later. Re-probe monthly. # unreachable -- the network failed. Worth remembering only long enough to # stop every apply in the next hour paying four 10-second # timeouts; then retry. # # An empty or unparseable marker is treated as EXPIRED. Releases up to 0.8.5 # wrote a content-free marker on ANY failure and deleted it never, so one # apply while offline cost that style its background permanently. Returning # $false here re-probes once and replaces it with a marker that can expire. param([string]$MarkerText, [datetime]$Now = [datetime]::UtcNow) if ([string]::IsNullOrWhiteSpace($MarkerText)) { return $false } try { $marker = $MarkerText | ConvertFrom-Json } catch { return $false } if (-not $marker -or -not $marker.kind -or -not $marker.at) { return $false } # AdjustToUniversal + AssumeUniversal, NOT RoundtripKind: TryParse with # RoundtripKind consumes the trailing Z but hands back Kind=Unspecified, so # a later .ToUniversalTime() re-reads a UTC stamp as local and shifts it by # the machine's offset. That silently widened or narrowed every TTL by hours # depending on where the user lives. The marker is always written as UTC. $at = [datetime]::MinValue $parseStyles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal if (-not [datetime]::TryParse($marker.at, [cultureinfo]::InvariantCulture, $parseStyles, [ref]$at)) { return $false } $ttl = if ($marker.kind -eq 'absent') { [timespan]::FromDays(30) } else { [timespan]::FromHours(1) } return (($Now.ToUniversalTime() - $at) -lt $ttl) } function Get-StyleBackgroundAbsence { <# .SYNOPSIS WHY this style has no background image: 'absent', 'unreachable' or 'unknown'. .DESCRIPTION Get-StyleBundledBackground already draws the distinction -- it is the whole reason the marker carries a `kind` and two different TTLs -- and then throws it away at the return, handing every caller one undifferentiated $null. So a style that genuinely ships no image and a style whose image could not be downloaded were the same answer, and `tstyles <style> -NewWindow` on Terminal.app returned in silence for both: no window, and not one word about the background, on the one command whose entire purpose is the background. Three answers rather than a boolean, for the reason Unregister-ShellLoader returns four: absent every extension answered 404. The style ships no image. unreachable the network did not answer. Nothing is known about the image. unknown no marker to read -- the cache dir could not be created or written, or nobody has probed yet. Read AFTER Get-StyleBundledBackground returns $null: that call either wrote a fresh marker or was suppressed by an unexpired one, so the marker on disk is the reason for the $null the caller is holding. Asked on its own it is a cache reading, not a probe -- it never touches the network. #> param([Parameter(Mandatory)][string]$StyleDir) $cacheDir = Get-StyleCacheDir -StyleName (Split-Path -Leaf $StyleDir) $markerPath = Join-Path $cacheDir '.no-background' if (-not (Test-Path -LiteralPath $markerPath)) { return 'unknown' } $markerText = '' try { $markerText = [System.IO.File]::ReadAllText($markerPath, [System.Text.UTF8Encoding]::new($false)) } catch { return 'unknown' } # An unparseable marker is 'unknown' for the same reason # Test-BackgroundProbeSuppressed calls it expired: the content-free markers # releases up to 0.8.5 wrote say nothing, and inventing a reason from one # would put a sentence on screen that no measurement backs. $marker = $null try { $marker = $markerText | ConvertFrom-Json } catch { return 'unknown' } if (-not $marker -or -not $marker.kind) { return 'unknown' } switch ("$($marker.kind)") { 'absent' { return 'absent' } 'unreachable' { return 'unreachable' } default { return 'unknown' } } } function Test-BackgroundRefreshDue { <# .SYNOPSIS Is it time to re-check a background this machine already has? .DESCRIPTION The negative cache below was given two carefully-reasoned lifetimes because "the gifs branch is updated independently of releases, so a style CAN gain an asset later". Every word of that applies to a style whose asset CHANGED, and only the 404 path got it: a cached file was returned unconditionally and never revalidated, so replacing an image on the gifs branch reached exactly the people who had never applied that style. Anyone who had was pinned to whatever they downloaded the first time, for good. Same marker shape as the negative cache, and the same rule that an empty or unparseable marker means "check now" -- a marker this tool cannot read must never be the reason it stops checking. checked the asset was compared against the server. Two weeks: the branch changes rarely, and the check costs a round trip on an interactive apply. unreachable the check failed. An hour, so a spell offline does not make every apply pay a timeout, and does not pin the image either. #> [CmdletBinding()] param([AllowNull()][string]$MarkerText, [datetime]$Now = [datetime]::UtcNow) if ([string]::IsNullOrWhiteSpace($MarkerText)) { return $true } try { $marker = $MarkerText | ConvertFrom-Json } catch { return $true } if (-not $marker -or -not $marker.kind -or -not $marker.at) { return $true } # Parsed exactly the way Test-BackgroundProbeSuppressed parses its stamp -- # RoundtripKind consumes the trailing Z but hands back Kind=Unspecified, so # a later ToUniversalTime shifts a UTC stamp by the machine's offset. $at = [datetime]::MinValue $styles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal if (-not [datetime]::TryParse($marker.at, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$at)) { return $true } $age = $Now - $at switch ($marker.kind) { 'checked' { return ($age.TotalDays -ge 14) } 'unreachable' { return ($age.TotalHours -ge 1) } default { return $true } } } function Test-BackgroundChanged { <# .SYNOPSIS Does the server's copy differ from the one on disk? .DESCRIPTION Pure, so the comparison can be tested without a network. Conservative in ONE direction on purpose: when there is nothing to compare -- no etag, no length -- it answers $false and keeps what is already there. A wrong "yes" costs a download of an image the user already has; a wrong "no" is just the bug this whole function exists to fix, so neither is free, but only one of them can loop. #> [CmdletBinding()] param( [AllowNull()][string]$LocalEtag, [AllowNull()][string]$RemoteEtag, [long]$LocalLength = 0, [long]$RemoteLength = 0 ) # An etag is the server telling us what it has. Trust it over a length, # which two different images can share. if ($RemoteEtag -and $LocalEtag) { return ($RemoteEtag -ne $LocalEtag) } if ($RemoteLength -gt 0 -and $LocalLength -gt 0) { return ($RemoteLength -ne $LocalLength) } return $false } function Get-BackgroundFileIn { # The background image sitting DIRECTLY in one directory, or $null. # # One place knows the extensions and their priority (.gif > .png > .jpg > # .jpeg, the order README documents for the gifs branch), because two # places answering the same question is how the tuner came to remove the # artefacts it owns while leaving behind the one file that outranks them # all: Save-TunedStyle replaced a style's profile.ps1 and prompt.sh and # kept its background.png, so a style the user had just been told was # REPLACED went on painting the replaced style's wallpaper. The removal # list and the resolution list are now the same list. param([Parameter(Mandatory)][string]$Directory) foreach ($ext in 'gif','png','jpg','jpeg') { $candidate = Join-Path $Directory "background.$ext" if (Test-Path -LiteralPath $candidate) { return $candidate } } return $null } function Update-CachedBackground { <# .SYNOPSIS Re-check a cached background against the gifs branch, and replace it if the server has a different one. Returns the path to use either way. .DESCRIPTION Never answers $null and never leaves the caller worse off: every failure path returns the file that was already there. The whole point is that a cached image stops being permanent, not that it becomes fragile -- losing a background to a flaky network would be a worse bug than the one this fixes. Throttled by a dated marker so an apply pays a round trip about twice a month rather than every time, and the HEAD gets a SHORT timeout: unlike the first fetch, there is already a perfectly good image on disk, so there is nothing worth waiting ten seconds for. -WebRequest is a test seam. Real callers omit it. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$CachePath, [Parameter(Mandatory)][string]$StyleName, [Parameter(Mandatory)][string]$CacheDir, [scriptblock]$WebRequest, [datetime]$Now = [datetime]::UtcNow ) $markerPath = Join-Path $CacheDir '.background-checked' $markerText = '' if (Test-Path -LiteralPath $markerPath) { try { $markerText = [System.IO.File]::ReadAllText($markerPath, [System.Text.UTF8Encoding]::new($false)) } catch { $markerText = '' } } if (-not (Test-BackgroundRefreshDue -MarkerText $markerText -Now $Now)) { return $CachePath } $ext = [System.IO.Path]::GetExtension($CachePath).TrimStart('.') $url = "https://raw.githubusercontent.com/fcreme/TerminalStyles/gifs/$StyleName.$ext" if (-not $WebRequest) { $WebRequest = { param($Uri, $Method, $OutFile) if ($OutFile) { Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop } else { Invoke-WebRequest -Uri $Uri -Method $Method -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop } } } $stamp = { param([string]$Kind, [string]$Etag) try { if (-not (Test-Path -LiteralPath $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force -ErrorAction Stop | Out-Null } $body = @{ kind = $Kind; at = $Now.ToString('o') } if ($Etag) { $body.etag = $Etag } [System.IO.File]::WriteAllText($markerPath, ($body | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false)) } catch { } } $localLength = 0 try { $localLength = (Get-Item -LiteralPath $CachePath -ErrorAction Stop).Length } catch { } $localEtag = '' if ($markerText) { try { $localEtag = "$(($markerText | ConvertFrom-Json).etag)" } catch { } } $prevProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' try { $head = & $WebRequest $url 'Head' $null $remoteEtag = '' $remoteLength = 0 if ($head -and $head.Headers) { # Header values arrive as a string on 5.1 and a string[] on 7. $raw = $head.Headers['ETag'] if ($raw) { $remoteEtag = "$(@($raw)[0])" } $len = $head.Headers['Content-Length'] if ($len) { [void][long]::TryParse("$(@($len)[0])", [ref]$remoteLength) } } if (-not (Test-BackgroundChanged -LocalEtag $localEtag -RemoteEtag $remoteEtag ` -LocalLength $localLength -RemoteLength $remoteLength)) { & $stamp 'checked' $remoteEtag return $CachePath } # Same .part-then-rename as the first fetch, for the same reason: a file # at the cache path is treated as complete by every reader, so a # half-written replacement would become this style's background. $part = "$CachePath.part-refresh" try { & $WebRequest $url 'Get' $part $got = Get-Item -LiteralPath $part -ErrorAction SilentlyContinue if ($got -and $got.Length -gt 0) { Move-Item -LiteralPath $part -Destination $CachePath -Force & $stamp 'checked' $remoteEtag return $CachePath } # Answered, but with nothing in it. Keep what we have. if ($got) { Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue } & $stamp 'checked' $localEtag return $CachePath } catch { if (Test-Path -LiteralPath $part) { Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue } & $stamp 'unreachable' $localEtag return $CachePath } } catch { & $stamp 'unreachable' $localEtag return $CachePath } finally { $ProgressPreference = $prevProgress } } function Get-StyleBundledBackground { # Three-tier resolution: # 1. Bundled file under $StyleDir (module root, read-only-ish on PSGallery) # 2. Cached file under $DataRoot\cache\<name>\ (writable, persistent) # 3. Lazy-fetch from gifs branch -> write to $DataRoot\cache\<name>\ # # The negative-cache marker (.no-background) lives in the cache dir, never # in the bundled dir, so we can write it on PSGallery installs. # # -NoFetch stops at tier 2: answer from disk, or answer $null, but never # reach for the network. It exists for callers on an interactive thread -- # the picker previews a style on every arrow key, and tier 3 can spend four # serial attempts at -TimeoutSec 10 before it concludes anything. Gating such # a caller on Test-StyleResolved instead would hold only while that predicate # and this function agree about an expired marker: a second implementation of # one rule, which is the drift this repo keeps paying for. A switch cannot # drift. It is threaded through the inheritance hop too, or a tuned style # would fetch its base's background on the thread we just protected. param( [Parameter(Mandatory)][string]$StyleDir, [switch]$NoInherit, [switch]$NoFetch ) # 1. Bundled (under module root) $bundled = Get-BackgroundFileIn -Directory $StyleDir if ($bundled) { return $bundled } $styleName = Split-Path -Leaf $StyleDir $cacheDir = Get-StyleCacheDir -StyleName $styleName # 2. Cached (under data root) $cached = Get-BackgroundFileIn -Directory $cacheDir if ($cached) { # Revalidated, but only where reaching for the network is allowed at # all. -NoFetch exists because the picker calls this on every arrow key # and tier 3 can spend four ten-second timeouts; a refresh check on that # thread would reintroduce exactly the stall that switch was added to # prevent. So an interactive preview keeps using what is on disk, and # the apply that follows is what picks up a changed asset. if (-not $NoFetch) { $cached = Update-CachedBackground -CachePath $cached -StyleName $styleName -CacheDir $cacheDir } return $cached } # 2b. Inheritance: a tuned style inherits its base's background. For a # non-tuned style this returns $null instantly (no tune.json), so the # normal path is unaffected. -NoInherit suppresses this (used when # resolving a base, so inheritance is strictly one hop -- no cycles). if (-not $NoInherit) { $inherited = Get-TunedBaseBackground -StyleDir $StyleDir -NoFetch:$NoFetch if ($inherited) { return $inherited } } $markerPath = Join-Path $cacheDir '.no-background' if (Test-Path -LiteralPath $markerPath) { $markerText = '' try { $markerText = [System.IO.File]::ReadAllText($markerPath, [System.Text.UTF8Encoding]::new($false)) } catch { $markerText = '' } if (Test-BackgroundProbeSuppressed -MarkerText $markerText) { return $null } } # 3. Lazy-fetch into cache if ($NoFetch) { return $null } if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } $remoteBase = "https://raw.githubusercontent.com/fcreme/TerminalStyles/gifs/$styleName" $prevProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' # Did the server actually answer "not there" every time? One unreachable # attempt is enough to make the whole result inconclusive. $definitelyAbsent = $true try { foreach ($ext in 'gif','png','jpg','jpeg') { $url = "$remoteBase.$ext" $local = Join-Path $cacheDir "background.$ext" # Same .part-then-rename as the picker's prefetch job, for the same # reason: a file at $local is treated as a complete cache entry by # every reader and nothing revalidates it. The catch below cleans up # after a network error, but a Ctrl+C or a killed process never # reaches it -- and that would strand a truncated image as this # style's background for good. # Per-writer temp name. This used to be a bare "$local.part", and # the picker's prefetch job derives its temp from the same cache # dir -- so on a first run the two raced on ONE path. The loser's # .part vanished mid-flight, Get-Item returned $null, and # `$null.Length -gt 0` took the "server sent an empty file" branch # instead of the catch -- so this call reported no background for a # style that has one. Measured: 3 of 4 consecutive picker runs lost # the race. # # Scope of the damage, precisely, because it is easy to overstate: # when the loser lost because the WINNER renamed the image into # place, the bogus 'absent' marker is never read again -- the cached # -file check at the top of this function returns first. The cost is # that ONE call: a preview or apply that writes a WT profile with no # backgroundImage, which persists in settings.json until the next # apply. The 30-day marker only bites in the other ordering, where # the sibling's catch unlinks an in-flight temp and no image lands # at all. # # Deterministic rather than a GUID, so a killed process leaves at # most one stale temp per writer per extension, which the next run # overwrites -- GUIDs would accumulate in the cache dir forever. $part = "$local.part-sync" try { Invoke-WebRequest -Uri $url -OutFile $part -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop $got = Get-Item -LiteralPath $part -ErrorAction SilentlyContinue if ($null -eq $got) { # The temp is GONE, not empty. The request succeeded, so # the server did not say "not there" -- something removed # the file underneath us. That is inconclusive, and the # distinction is the whole point of $definitelyAbsent: # calling it absent buys a 30-DAY marker for a style that # may well have a background. # # It used to fall into the else below, because # `$null.Length -gt 0` is simply false -- so a lost race # was recorded as a definite absence. $definitelyAbsent = $false } elseif ($got.Length -gt 0) { Move-Item -LiteralPath $part -Destination $local -Force return $local } else { # A real zero-byte response. The server answered; there is # just nothing in it. Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue } } catch { if (Test-Path -LiteralPath $part) { Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue } if (-not (Test-HttpNotFound -ErrorRecord $_)) { $definitelyAbsent = $false } } } } finally { $ProgressPreference = $prevProgress } # Nothing fetched. Record WHY, so the marker can expire on the right clock: # a real 404 is worth remembering for a month, an unreachable network for an # hour. Writing an undated marker on any failure -- what releases up to 0.8.5 # did -- meant a single apply while offline cost that style its background # for good, and the only documented way to clear it was `tstyles uninstall # -DeleteData`, which could not be invoked at all. try { $marker = [pscustomobject]@{ schemaVersion = 1 kind = if ($definitelyAbsent) { 'absent' } else { 'unreachable' } at = [datetime]::UtcNow.ToString('o') } [System.IO.File]::WriteAllText((Join-Path $cacheDir '.no-background'), ($marker | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false)) } catch { } return $null } |