diff --git a/docs/customization.md b/docs/customization.md index 3ebd4dfb..6ce0a0d4 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -156,7 +156,7 @@ to say โ€” fastfetch hides a module entirely when it prints nothing, so a health machine stays clean: ```console -Updates: ๐Ÿ“ฆ 6 update(s) available (winget) +Updates: ๐Ÿ“ฆ 6 update(s) available (winget) ยท checked 5m ago Reboot: ๐Ÿ”„ Reboot required โ€” linux-image-generic Ansible: โœ… ansible-pull OK ยท ran 5m ago ยท next in 25m ``` @@ -178,6 +178,12 @@ the next login. On Windows fastfetch reads the cache with `cmd /c type` rather than starting PowerShell, which keeps the line free (a few milliseconds). +The `checked 5m ago` part is deliberately not stored in the cache โ€” that would +freeze it until the next hourly refresh. Each section is kept twice: a source +file holding the moment as an absolute timestamp, and the rendered line the +banner shows. Expanding one into the other is pure arithmetic and happens on +every shell start, so the age is always correct when you read it. + Refresh by hand โ€” useful right after installing updates: ```bash @@ -195,6 +201,20 @@ Refresh by hand โ€” useful right after installing updates: | `FASTFETCH_STATUS_CACHE_DIR` | Override the cache directory (Windows; testing) | | `ANSIBLE_PULL_WORKDIR` | ansible-pull checkout (default `/var/lib/ansible/local`) | +### A quiet PowerShell startup + +fastfetch already reports the shell version and everything else worth knowing, +so the noise around it is turned off rather than printed twice: + +- `PowerShell 7.6.4` and `Loading personal and system profiles took 1513ms.` are + printed by pwsh itself, before and after the profile runs. They cannot be + suppressed from `profile.ps1`, so the Windows Terminal PowerShell profile is + launched as `pwsh.exe -NoLogo -NoProfileLoadTime` instead (set by + `run_onchange_set-windows-terminal-powershell-args.ps1`). +- The dotfiles' own `[OK] PowerShell Profile Loaded` and `Type 'aliases'` lines + are skipped whenever the fastfetch banner rendered. On a light install without + fastfetch they stay, so an interactive shell still confirms the profile loaded. + ## Windows PATH On Windows, the setup adds `%OneDrive%\Portable Programs` to the user-scope diff --git a/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl b/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl new file mode 100644 index 00000000..d55bac56 --- /dev/null +++ b/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl @@ -0,0 +1,43 @@ +# {{ if eq .chezmoi.os "windows" }} +# Start the Windows Terminal PowerShell profile quietly. +# +# Two lines of noise appear before anything useful: +# +# PowerShell 7.6.4 +# Loading personal and system profiles took 1513ms. +# +# Both are printed by pwsh itself around profile load, so they cannot be +# suppressed from profile.ps1 -- the banner is written before the profile runs +# and the timing line after it. The only lever is the command line the terminal +# starts, hence -NoLogo -NoProfileLoadTime here. fastfetch already reports the +# shell version in its banner, so nothing is lost. +# +# pwsh.exe is left to PATH resolution rather than pinned to an absolute path so +# the profile keeps working across PowerShell upgrades and reinstalls. +# +# As with the sibling default-profile script, Windows Terminal owns +# settings.json and every machine has a different, hand-tuned config, so only +# this one profile's commandline is touched. +$ErrorActionPreference = "Stop" + +$modulePath = "{{ .chezmoi.sourceDir }}\dot_config\powershell\modules\DotfilesHelpers" +if (Test-Path $modulePath) { + Import-Module $modulePath -Force -DisableNameChecking +} +else { + Write-Error "DotfilesHelpers module not found at: $modulePath" + exit 1 +} + +Write-Host "Setting Windows Terminal PowerShell startup arguments..." -ForegroundColor Cyan +$results = Set-WindowsTerminalProfileCommandLine ` + -Source "Windows.Terminal.PowershellCore" ` + -CommandLine "pwsh.exe -NoLogo -NoProfileLoadTime" + +if (-not $results) { + Write-Host "[SKIP] No Windows Terminal settings found to update" -ForegroundColor Yellow +} +elseif (-not ($results | Where-Object { $_.Status -in @('Updated', 'AlreadySet') })) { + Write-Host "[SKIP] PowerShell Core profile not found; leaving commandline unchanged" -ForegroundColor Yellow +} +# {{ end }} diff --git a/home/dot_config/fastfetch/executable_status.sh b/home/dot_config/fastfetch/executable_status.sh index bb3d084a..8594cd04 100755 --- a/home/dot_config/fastfetch/executable_status.sh +++ b/home/dot_config/fastfetch/executable_status.sh @@ -18,11 +18,12 @@ # # Relative times ("ran 5m ago", "next in 20m") are NOT baked into the cache # โ€” that would freeze them until the next refresh. The collectors store -# absolute epoch tokens (@ago:@ / @in:@) and the section -# commands expand them against the current clock on every fastfetch run. -# Expansion itself is pure shell arithmetic; reading the clock uses the -# EPOCHSECONDS builtin on bash >= 5.0 and falls back to one `date` call on -# older bash, so the emit path costs at most a single fork. +# absolute epoch tokens (@ago:@ / @in:@) that expand to a bare +# duration phrase ("5m ago" / "in 20m"), and the section commands expand them +# against the current clock on every fastfetch run. Expansion itself is pure +# shell arithmetic; reading the clock uses the EPOCHSECONDS builtin on +# bash >= 5.0 and falls back to one `date` call on older bash, so the emit +# path costs at most a single fork. # # Usage: status.sh # updates Print cached "updates available" line (may be empty) @@ -90,8 +91,10 @@ _now() { } # _render LINE NOW -> print LINE with relative-time tokens expanded: -# @ago:@ -> "ran 5m ago" (elapsed since ) -# @in:@ -> "next in 20m", or "next due" once has passed +# @ago:@ -> "5m ago" (elapsed since ) +# @in:@ -> "in 20m", or "due" once has passed +# Tokens expand to a bare duration phrase; the collectors own the wording +# around them (e.g. "ran @ago:โ€ฆ@", "next @in:โ€ฆ@", "checked @ago:โ€ฆ@"). # Unknown or malformed tokens are left untouched. _render() { local line="${1}" now="${2}" tail="" pre kind epoch token @@ -103,12 +106,12 @@ _render() { token="" if [ "${kind}" = "ago" ]; then _fmt_duration "$((now - epoch))" - token="ran ${REL_DURATION} ago" + token="${REL_DURATION} ago" elif [ "${epoch}" -gt "${now}" ]; then _fmt_duration "$((epoch - now))" - token="next in ${REL_DURATION}" + token="in ${REL_DURATION}" else - token="next due" + token="due" fi tail="${token}${BASH_REMATCH[4]}${tail}" line="${pre}" @@ -194,7 +197,13 @@ _collect_updates() { count="${count//[!0-9]/}" [ -z "${count}" ] && count=0 if [ "${count}" -gt 0 ]; then - printf '\360\237\223\246 %s update(s) available (%s)\n' "${count}" "${mgr}" + # The "checked" suffix is an absolute epoch token so it keeps counting + # up between refreshes instead of freezing at the value it had when + # the cache was written. + local checked_at + checked_at="$(_now)" + printf '\360\237\223\246 %s update(s) available (%s) \302\267 checked @ago:%s@\n' \ + "${count}" "${mgr}" "${checked_at}" fi } @@ -273,7 +282,7 @@ _collect_ansible() { if [ -n "${finished_ts}" ]; then local finished_epoch finished_epoch="$(date -d "${finished_ts}" +%s 2>/dev/null || echo 0)" - [ "${finished_epoch}" -gt 0 ] && ran="@ago:${finished_epoch}@" + [ "${finished_epoch}" -gt 0 ] && ran="ran @ago:${finished_epoch}@" fi # Next scheduled run from the timer (microseconds since epoch). @@ -282,7 +291,7 @@ _collect_ansible() { next_us="${next_us//[!0-9]/}" if [ -n "${next_us}" ] && [ "${next_us}" -gt 0 ]; then next_epoch=$((next_us / 1000000)) - [ "${next_epoch}" -gt "${now}" ] && next="@in:${next_epoch}@" + [ "${next_epoch}" -gt "${now}" ] && next="next @in:${next_epoch}@" fi local head tail="" part diff --git a/home/dot_config/fastfetch/status.ps1 b/home/dot_config/fastfetch/status.ps1 index f454c714..bd2e8f0e 100644 --- a/home/dot_config/fastfetch/status.ps1 +++ b/home/dot_config/fastfetch/status.ps1 @@ -19,6 +19,14 @@ * The PowerShell profile spawns a detached 'refresh' when the cache is older than the TTL, so the next login shows fresh numbers. + Each section is stored twice: '.src' keeps relative times as absolute + epoch tokens (@ago:@), and '' is the rendered copy fastfetch + reads. Baking "checked 5m ago" straight into the rendered file would freeze + it until the next refresh an hour later, so the tokens are expanded on every + shell start by Update-FastfetchStatusCache (DotfilesHelpers), which the + profile already imports. This script calls the same function after a refresh + so a manual 'refresh' also leaves a correctly rendered cache behind. + fastfetch hides a `command` module entirely when it prints nothing, so a host with no pending updates simply does not get an "Updates" line. @@ -59,10 +67,12 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' -# U+1F4E6 PACKAGE. Built from its code point because .ps1 files in this repo -# must stay ASCII (see .github/copilot-instructions.md): PowerShell 5.1 reads -# unsigned scripts without a BOM as ANSI, which corrupts literal emoji. +# U+1F4E6 PACKAGE and U+00B7 MIDDLE DOT. Built from their code points because +# .ps1 files in this repo must stay ASCII (see .github/copilot-instructions.md): +# PowerShell 5.1 reads unsigned scripts without a BOM as ANSI, which corrupts +# literal non-ASCII characters. $script:PackageIcon = [char]::ConvertFromUtf32(0x1F4E6) +$script:MiddleDot = [char]::ConvertFromUtf32(0x00B7) function Get-StatusCacheDir { if ($env:FASTFETCH_STATUS_CACHE_DIR) { return $env:FASTFETCH_STATUS_CACHE_DIR } @@ -171,19 +181,32 @@ function Get-UpdatesLine { <# .SYNOPSIS The "N update(s) available (winget)" line, or an empty string. + + .DESCRIPTION + The "checked" suffix is emitted as an absolute epoch token rather than a + formatted duration, so it keeps counting up on every shell start instead of + freezing at the value it had when the cache was written. #> [CmdletBinding()] param() $count = Get-WingetUpdateCount if ($null -eq $count -or $count -le 0) { return '' } - return ('{0} {1} update(s) available (winget)' -f $script:PackageIcon, $count) + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + return ('{0} {1} update(s) available (winget) {2} checked @ago:{3}@' -f + $script:PackageIcon, $count, $script:MiddleDot, $now) } function Write-StatusSection { <# .SYNOPSIS - Atomically write a cache section, or remove it when the value is empty. + Atomically write a cache section source, or remove it when the value is empty. + + .DESCRIPTION + Writes '.src' (with relative-time tokens intact). The rendered + '' file that fastfetch reads is produced from it by + Update-FastfetchStatusCache. #> [CmdletBinding(SupportsShouldProcess)] param( @@ -191,11 +214,13 @@ function Write-StatusSection { [Parameter(Mandatory)][AllowEmptyString()][string]$Value ) - $file = Join-Path (Get-StatusCacheDir) $Name + $cacheDir = Get-StatusCacheDir + $file = Join-Path $cacheDir "$Name.src" if (-not $PSCmdlet.ShouldProcess($file, 'Write status section')) { return } if ([string]::IsNullOrEmpty($Value)) { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $cacheDir $Name) -Force -ErrorAction SilentlyContinue return } @@ -207,6 +232,38 @@ function Write-StatusSection { Move-Item -LiteralPath $tmp -Destination $file -Force } +function Invoke-StatusRender { + <# + .SYNOPSIS + Expand the cached tokens into the rendered files fastfetch reads. + + .DESCRIPTION + Rendering lives in the DotfilesHelpers module because the PowerShell profile + imports it anyway, so expanding tokens on every shell start is free there + (dot-sourcing this script instead would add ~60ms to profile load). This + script imports the module so a manual 'refresh' also leaves a correctly + rendered cache behind; when the module is missing the rendered files are + simply left to the next shell start. + #> + [CmdletBinding()] + param() + + try { + if (-not (Get-Command Update-FastfetchStatusCache -ErrorAction SilentlyContinue)) { + $modulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'powershell\modules\DotfilesHelpers' + if (-not (Test-Path -LiteralPath $modulePath)) { + Write-Verbose "DotfilesHelpers not found at '$modulePath'; skipping render." + return + } + Import-Module $modulePath -DisableNameChecking -ErrorAction Stop + } + Update-FastfetchStatusCache -SkipRefresh + } + catch { + Write-Verbose "Could not render the status cache: $($_.Exception.Message)" + } +} + function Invoke-StatusEmit { [CmdletBinding()] param([Parameter(Mandatory)][string]$Name) @@ -264,6 +321,7 @@ function Invoke-StatusRefresh { Write-StatusSection -Name 'updates' -Value (Get-UpdatesLine) $stamp = Join-Path $cacheDir '.refreshed-at' [System.IO.File]::WriteAllText($stamp, '') + Invoke-StatusRender } finally { $handle.Dispose() diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 b/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 index f7184ba2..0e9a7d98 100644 --- a/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 +++ b/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 @@ -51,6 +51,16 @@ # Windows Terminal settings 'Set-WindowsTerminalDefaultProfile' 'Set-WindowsTerminalCopilotProfile' + 'Set-WindowsTerminalProfileCommandLine' + + # fastfetch status cache + 'Get-FastfetchStatusCacheDir' + 'Format-FastfetchStatusDuration' + 'Expand-FastfetchStatusToken' + 'Update-FastfetchStatusCache' + 'Test-FastfetchStatusStale' + 'Start-FastfetchStatusRefresh' + 'Write-FastfetchStatusFile' # Office 365 mail aliases 'New-MailAlias' diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 b/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 new file mode 100644 index 00000000..5445ae66 --- /dev/null +++ b/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 @@ -0,0 +1,350 @@ +# fastfetch status cache helpers (Windows) +# +# The fastfetch banner carries extra status lines (currently the winget update +# count) that are far too slow to compute while a shell starts, so they are +# served from a small cache under %LOCALAPPDATA%\fastfetch-status: +# +# updates.src the line as written by status.ps1, with relative-time +# tokens still in it (@ago:@ / @in:@) +# updates the rendered line, which fastfetch reads with `cmd /c type` +# .refreshed-at marker file whose mtime dates the cache +# +# Relative times are deliberately NOT baked into the rendered file by the +# refresh: that would freeze "checked 5m ago" until the next refresh an hour +# later. Instead the tokens are expanded on every shell start, just before +# fastfetch runs. That is what Update-FastfetchStatusCache does, and it is why +# it lives in this module (which the profile imports anyway) rather than in +# status.ps1 (dot-sourcing that script costs ~60ms of profile load). +# +# This mirrors home/dot_config/fastfetch/executable_status.sh, which expands +# the same token syntax inline because its emit path is already a shell script. + +function Get-FastfetchStatusCacheDir { + <# + .SYNOPSIS + Path of the fastfetch status cache directory. + + .DESCRIPTION + Honours FASTFETCH_STATUS_CACHE_DIR (used by tests and by anyone who wants + the cache somewhere else), then %LOCALAPPDATA%, then ~/.cache as a last + resort. Kept in sync with the same lookup in status.ps1. + + .EXAMPLE + Get-FastfetchStatusCacheDir + #> + [CmdletBinding()] + [OutputType([string])] + param() + + if ($env:FASTFETCH_STATUS_CACHE_DIR) { return $env:FASTFETCH_STATUS_CACHE_DIR } + $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path $HOME '.cache' } + return (Join-Path $base 'fastfetch-status') +} + +function Format-FastfetchStatusDuration { + <# + .SYNOPSIS + Formats a number of seconds as a compact duration (45s, 12m, 3h, 2d). + + .PARAMETER Seconds + Seconds to format. Negative values are treated as zero, which happens when + a clock change leaves a cache timestamp slightly in the future. + + .EXAMPLE + Format-FastfetchStatusDuration -Seconds 3600 + Returns '1h'. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [double]$Seconds + ) + + $s = [int][math]::Floor($Seconds) + if ($s -lt 0) { $s = 0 } + + if ($s -lt 60) { return "${s}s" } + if ($s -lt 3600) { return "$([int][math]::Floor($s / 60))m" } + if ($s -lt 86400) { return "$([int][math]::Floor($s / 3600))h" } + return "$([int][math]::Floor($s / 86400))d" +} + +function Expand-FastfetchStatusToken { + <# + .SYNOPSIS + Expands relative-time tokens in a cached status line. + + .DESCRIPTION + Replaces the absolute epoch tokens written by the collectors with a bare + duration phrase, so the surrounding wording stays in the collector: + + @ago:@ -> '5m ago' + @in:@ -> 'in 20m', or 'due' once the moment has passed + + Malformed tokens are left untouched rather than throwing, so a corrupt + cache degrades to a slightly ugly line instead of breaking the banner. + + .PARAMETER Line + The cached line to expand. + + .PARAMETER Now + Current time as Unix epoch seconds. Defaults to the current clock. + + .EXAMPLE + Expand-FastfetchStatusToken -Line 'checked @ago:1700000000@' + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Now', Justification = 'Captured by the regex MatchEvaluator closure below, which the analyzer cannot see into.')] + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Line, + + [Parameter()] + [long]$Now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + ) + + $evaluator = { + param($match) + + $kind = $match.Groups[1].Value + $epoch = [long]$match.Groups[2].Value + + if ($kind -eq 'ago') { + return ('{0} ago' -f (Format-FastfetchStatusDuration -Seconds ($Now - $epoch))) + } + if ($epoch -gt $Now) { + return ('in {0}' -f (Format-FastfetchStatusDuration -Seconds ($epoch - $Now))) + } + return 'due' + } + + return [regex]::Replace($Line, '@(ago|in):(\d+)@', $evaluator) +} + +function Update-FastfetchStatusCache { + <# + .SYNOPSIS + Renders the fastfetch status cache and refreshes it in the background when stale. + + .DESCRIPTION + Call this from an interactive profile just before running fastfetch. It does + two cheap things and never blocks: + + 1. Expands every '
.src' in the cache into its rendered + '
' sibling, so relative times are correct as of right now. + 2. When the cache is older than the TTL, starts a detached + 'status.ps1 refresh' so the NEXT shell sees fresh numbers. The current + shell deliberately keeps showing the stale-but-instant values + (stale-while-revalidate); a winget query takes tens of seconds and must + never sit on the login path. + + Rendered files whose '.src' has disappeared are removed, which is how a + section stops being displayed once it has nothing to report (fastfetch hides + a 'command' module entirely when it prints nothing). + + Failures are swallowed: a missing status line is never a reason to break a + shell. + + .PARAMETER SkipRefresh + Only render; never spawn a background refresh. Used by status.ps1 itself + after a refresh it has just completed. + + .PARAMETER CacheDir + Cache directory. Defaults to Get-FastfetchStatusCacheDir. + + .PARAMETER StatusScript + Path to status.ps1. Defaults to the copy next to this module in the chezmoi + tree (~/.config/fastfetch/status.ps1). + + .EXAMPLE + Update-FastfetchStatusCache + Renders the cache and kicks off a background refresh when it is stale. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter()] + [switch]$SkipRefresh, + + [Parameter()] + [string]$CacheDir, + + [Parameter()] + [string]$StatusScript + ) + + if ($env:FASTFETCH_STATUS_DISABLE -eq '1') { return } + + if (-not $CacheDir) { $CacheDir = Get-FastfetchStatusCacheDir } + if (-not (Test-Path -LiteralPath $CacheDir)) { + # Nothing cached yet. Still worth spawning the first refresh. + if (-not $SkipRefresh) { Start-FastfetchStatusRefresh -StatusScript $StatusScript } + return + } + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + + foreach ($src in @(Get-ChildItem -LiteralPath $CacheDir -Filter '*.src' -File -ErrorAction SilentlyContinue)) { + $rendered = Join-Path $CacheDir ([System.IO.Path]::GetFileNameWithoutExtension($src.Name)) + try { + $lines = @(Get-Content -LiteralPath $src.FullName -Encoding UTF8 -ErrorAction Stop | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { Expand-FastfetchStatusToken -Line $_ -Now $now }) + + if (-not $PSCmdlet.ShouldProcess($rendered, 'Render fastfetch status section')) { continue } + + if ($lines.Count -eq 0) { + Remove-Item -LiteralPath $rendered -Force -ErrorAction SilentlyContinue + continue + } + + Write-FastfetchStatusFile -Path $rendered -Lines $lines + } + catch { + Write-Verbose "Could not render fastfetch status section '$rendered': $($_.Exception.Message)" + } + } + + # Drop rendered files whose source is gone (section no longer applies). + foreach ($stale in @(Get-ChildItem -LiteralPath $CacheDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike '*.src' -and $_.Name -notlike '.*' })) { + if (-not (Test-Path -LiteralPath "$($stale.FullName).src")) { + if ($PSCmdlet.ShouldProcess($stale.FullName, 'Remove orphaned fastfetch status section')) { + Remove-Item -LiteralPath $stale.FullName -Force -ErrorAction SilentlyContinue + } + } + } + + if (-not $SkipRefresh -and (Test-FastfetchStatusStale -CacheDir $CacheDir)) { + Start-FastfetchStatusRefresh -StatusScript $StatusScript + } +} + +function Test-FastfetchStatusStale { + <# + .SYNOPSIS + True when the fastfetch status cache is missing or older than the TTL. + + .PARAMETER CacheDir + Cache directory. Defaults to Get-FastfetchStatusCacheDir. + + .EXAMPLE + Test-FastfetchStatusStale + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter()] + [string]$CacheDir + ) + + if (-not $CacheDir) { $CacheDir = Get-FastfetchStatusCacheDir } + $stamp = Join-Path $CacheDir '.refreshed-at' + if (-not (Test-Path -LiteralPath $stamp)) { return $true } + + $ttl = 3600 + $parsed = 0 + if ($env:FASTFETCH_STATUS_TTL -and + [int]::TryParse($env:FASTFETCH_STATUS_TTL, [ref]$parsed) -and + $parsed -gt 0) { + $ttl = $parsed + } + + $age = (Get-Date) - (Get-Item -LiteralPath $stamp).LastWriteTime + return ($age.TotalSeconds -ge $ttl) +} + +function Start-FastfetchStatusRefresh { + <# + .SYNOPSIS + Starts a detached 'status.ps1 refresh' without blocking the caller. + + .DESCRIPTION + The refresh queries winget, which takes tens of seconds, so it runs in a + separate hidden process that nobody waits on. Its output is silenced inside + the child as well as redirected, because no one drains those pipes: a chatty + child would otherwise block once the pipe buffer filled. + + .PARAMETER StatusScript + Path to status.ps1. Defaults to ~/.config/fastfetch/status.ps1. + + .EXAMPLE + Start-FastfetchStatusRefresh + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter()] + [string]$StatusScript + ) + + if (-not $StatusScript) { + $StatusScript = Join-Path $HOME '.config\fastfetch\status.ps1' + } + if (-not (Test-Path -LiteralPath $StatusScript)) { + Write-Verbose "fastfetch status script not found at: $StatusScript" + return + } + + $psHost = (Get-Process -Id $PID).Path + if (-not $psHost) { + Write-Verbose 'Could not resolve the current PowerShell host executable.' + return + } + + if (-not $PSCmdlet.ShouldProcess($StatusScript, 'Start background fastfetch status refresh')) { return } + + try { + $command = "& '{0}' refresh *>`$null" -f $StatusScript.Replace("'", "''") + $info = [System.Diagnostics.ProcessStartInfo]::new($psHost) + $info.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "{0}"' -f $command + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + [System.Diagnostics.Process]::Start($info) | Out-Null + } + catch { + Write-Verbose "Could not start fastfetch status refresh: $($_.Exception.Message)" + } +} + +function Write-FastfetchStatusFile { + <# + .SYNOPSIS + Atomically writes cache lines as UTF-8 without a BOM. + + .DESCRIPTION + fastfetch reads the rendered file with `cmd /c type`, which copies bytes + straight through, so a BOM would surface as stray characters in the banner. + The write goes to a temporary file first so a reader never sees a half + written line. + + .PARAMETER Path + Destination file. + + .PARAMETER Lines + Lines to write. + + .EXAMPLE + Write-FastfetchStatusFile -Path $cache -Lines '6 update(s) available' + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$Lines + ) + + if (-not $PSCmdlet.ShouldProcess($Path, 'Write fastfetch status file')) { return } + + $encoding = New-Object System.Text.UTF8Encoding($false) + $content = ($Lines -join "`r`n") + "`r`n" + $tmp = "$Path.tmp" + [System.IO.File]::WriteAllText($tmp, $content, $encoding) + Move-Item -LiteralPath $tmp -Destination $Path -Force +} diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/Public/WindowsTerminal.ps1 b/home/dot_config/powershell/modules/DotfilesHelpers/Public/WindowsTerminal.ps1 index 6196a593..aa206370 100644 --- a/home/dot_config/powershell/modules/DotfilesHelpers/Public/WindowsTerminal.ps1 +++ b/home/dot_config/powershell/modules/DotfilesHelpers/Public/WindowsTerminal.ps1 @@ -363,6 +363,149 @@ function Set-WindowsTerminalDefaultProfile { return $results } +function Set-WindowsTerminalProfileCommandLine { + <# + .SYNOPSIS + Sets the commandline of an existing Windows Terminal profile. + + .DESCRIPTION + Parses each existing Windows Terminal settings.json, finds the matching + profile in profiles.list (by source, e.g. 'Windows.Terminal.PowershellCore', + or by display name), and surgically sets that profile's 'commandline'. Only + that one key is touched, so the rest of a hand-tuned config survives intact. + + This is how launch-time switches get applied: options like -NoLogo and + -NoProfileLoadTime are read by PowerShell before any profile runs, so they + cannot be set from within profile.ps1 and have to live on the command line + the terminal starts. + + Dynamically generated profiles (the ones carrying a 'source') accept an + explicit commandline, which overrides the generator's default. + + When no matching profile exists (e.g. PowerShell Core is not installed on + that machine) the file is left unchanged and the result is reported as + 'ProfileNotFound' rather than raising an error, so callers can skip cleanly. + + .PARAMETER CommandLine + The command line to set, e.g. 'pwsh.exe -NoLogo -NoProfileLoadTime'. + + .PARAMETER Source + The profile 'source' to match, e.g. 'Windows.Terminal.PowershellCore' + (default). Used when -ProfileName is not supplied. + + .PARAMETER ProfileName + Match a profile by its display 'name' instead of its 'source'. + + .PARAMETER SettingsPath + One or more settings.json paths. Defaults to the known Windows Terminal + locations (Store, Preview, and unpackaged). + + .EXAMPLE + Set-WindowsTerminalProfileCommandLine -CommandLine 'pwsh.exe -NoLogo' + Starts the PowerShell Core profile without the startup banner. + + .EXAMPLE + Set-WindowsTerminalProfileCommandLine -ProfileName 'Debian' -CommandLine 'wsl -d Debian' + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Matches sibling Windows Terminal helpers that print visible status after a change.')] + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'BySource')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$CommandLine, + + [Parameter(ParameterSetName = 'BySource')] + [ValidateNotNullOrEmpty()] + [string]$Source = 'Windows.Terminal.PowershellCore', + + [Parameter(Mandatory, ParameterSetName = 'ByName')] + [ValidateNotNullOrEmpty()] + [string]$ProfileName, + + [Parameter()] + [string[]]$SettingsPath + ) + + if (-not $SettingsPath) { + $SettingsPath = Get-WindowsTerminalSettingsPath + } + + if ($PSCmdlet.ParameterSetName -eq 'ByName') { + $criteria = "name '$ProfileName'" + } + else { + $criteria = "source '$Source'" + } + + $results = @() + + foreach ($path in $SettingsPath) { + if (-not (Test-Path -LiteralPath $path)) { + Write-Verbose "Windows Terminal settings not found at: $path (skipping)" + continue + } + + try { + $json = Read-WindowsTerminalSettings -Path $path + + $list = $null + if ($json.PSObject.Properties['profiles'] -and $json.profiles.PSObject.Properties['list']) { + $list = @($json.profiles.list) + } + + if (-not $list) { + Write-Verbose "No profiles.list found in: $path (skipping)" + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'ProfileNotFound'; MatchedProfile = $null } + continue + } + + if ($PSCmdlet.ParameterSetName -eq 'ByName') { + $match = $list | Where-Object { $_.PSObject.Properties['name'] -and $_.name -eq $ProfileName } | Select-Object -First 1 + } + else { + $match = $list | Where-Object { $_.PSObject.Properties['source'] -and $_.source -eq $Source } | Select-Object -First 1 + } + + if (-not $match) { + Write-Verbose "No profile matching $criteria found in: $path (skipping)" + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'ProfileNotFound'; MatchedProfile = $null } + continue + } + + $name = if ($match.PSObject.Properties['name']) { [string]$match.name } else { $criteria } + + if ($match.PSObject.Properties['commandline'] -and [string]$match.commandline -eq $CommandLine) { + Write-Verbose "commandline already set for $criteria at: $path" + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'AlreadySet'; MatchedProfile = $name } + continue + } + + if ($null -eq $match.PSObject.Properties['commandline']) { + $match | Add-Member -NotePropertyName commandline -NotePropertyValue $CommandLine -Force + } + else { + $match.commandline = $CommandLine + } + + if ($PSCmdlet.ShouldProcess($path, "Set commandline for '$name' to $CommandLine")) { + Save-WindowsTerminalSettings -Path $path -Settings $json + Write-Host "[OK] Set Windows Terminal commandline for '$name' at: $path" -ForegroundColor Green + $results += [pscustomobject]@{ Path = $path; Changed = $true; Status = 'Updated'; MatchedProfile = $name } + } + else { + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'WhatIf'; MatchedProfile = $name } + } + } + catch { + Write-Warning "Could not update Windows Terminal commandline at '$path': $_" + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'Error'; MatchedProfile = $null } + } + } + + return $results +} + function Set-WindowsTerminalCopilotProfile { <# .SYNOPSIS diff --git a/home/dot_config/powershell/profile.ps1 b/home/dot_config/powershell/profile.ps1 index 3c901fbc..4b4c1e0f 100644 --- a/home/dot_config/powershell/profile.ps1 +++ b/home/dot_config/powershell/profile.ps1 @@ -6,6 +6,10 @@ # Measure profile load time for performance diagnostics $script:_profileLoadStart = [System.Diagnostics.Stopwatch]::StartNew() +# Set when the fastfetch banner rendered, so the welcome lines below can stay +# out of the way. +$script:_fastfetchShown = $false + # Set UTF-8 encoding (force code page 65001 for Windows PowerShell 5.1) if ($PSVersionTable.PSVersion.Major -lt 7) { chcp 65001 | Out-Null @@ -112,54 +116,17 @@ if (Test-Path $completionsPath) { if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { $fastfetchCmd = Get-Command fastfetch -ErrorAction SilentlyContinue if ($fastfetchCmd) { - # Stale-while-revalidate for the extra "Updates" module: fastfetch reads - # a cache file with `cmd /c type`, and the expensive winget query runs - # here in a detached background process. The banner below therefore - # shows the previous (possibly slightly stale) numbers instantly while a - # fresh copy is computed for the next login. Mirrors status.sh on Unix, - # which can self-spawn its refresh because its emit path is already a - # shell script. - try { - $statusScript = Join-Path (Split-Path $PSScriptRoot -Parent) 'fastfetch\status.ps1' - $statusCacheDir = if ($env:FASTFETCH_STATUS_CACHE_DIR) { - $env:FASTFETCH_STATUS_CACHE_DIR - } else { - Join-Path $env:LOCALAPPDATA 'fastfetch-status' - } - $statusStamp = Join-Path $statusCacheDir '.refreshed-at' - - $statusTtlSeconds = 3600 - $parsedTtl = 0 - if ($env:FASTFETCH_STATUS_TTL -and - [int]::TryParse($env:FASTFETCH_STATUS_TTL, [ref]$parsedTtl) -and - $parsedTtl -gt 0) { - $statusTtlSeconds = $parsedTtl - } - - $statusStale = -not (Test-Path -LiteralPath $statusStamp) - if (-not $statusStale) { - $statusAge = (Get-Date) - (Get-Item -LiteralPath $statusStamp).LastWriteTime - $statusStale = $statusAge.TotalSeconds -ge $statusTtlSeconds - } - - if ($statusStale -and $env:FASTFETCH_STATUS_DISABLE -ne '1' -and (Test-Path -LiteralPath $statusScript)) { - $statusHost = (Get-Process -Id $PID).Path - if ($statusHost) { - # Output is silenced inside the child (*>$null) as well as - # redirected, because nobody drains these pipes: a chatty - # child would otherwise block once the pipe buffer filled. - $statusCommand = "& '{0}' refresh *>`$null" -f $statusScript.Replace("'", "''") - $statusInfo = [System.Diagnostics.ProcessStartInfo]::new($statusHost) - $statusInfo.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "{0}"' -f $statusCommand - $statusInfo.UseShellExecute = $false - $statusInfo.CreateNoWindow = $true - $statusInfo.RedirectStandardOutput = $true - $statusInfo.RedirectStandardError = $true - [System.Diagnostics.Process]::Start($statusInfo) | Out-Null - } + # Render the extra status lines (winget updates) and, when the cache has + # aged out, kick off a detached refresh for the next shell. Both are + # cheap; the expensive winget query never runs on this path. See + # DotfilesHelpers/Public/FastfetchStatus.ps1. + if (Get-Command Update-FastfetchStatusCache -ErrorAction SilentlyContinue) { + try { + Update-FastfetchStatusCache + } catch { + # A missing status line is never a reason to break the shell. + Write-Verbose "fastfetch status cache update failed: $($_.Exception.Message)" } - } catch { - # A missing cache only costs an "Updates" line; never break the shell. } # Allow users to tune the timeout (milliseconds) via an env var. @@ -182,6 +149,8 @@ if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { if (-not $fastfetchProc.WaitForExit($fastfetchTimeoutMs)) { try { $fastfetchProc.Kill() } catch { } Write-Host "`n(fastfetch timed out after $([math]::Round($fastfetchTimeoutMs / 1000, 1))s; skipping)" -ForegroundColor DarkYellow + } else { + $script:_fastfetchShown = $true } } catch { Write-Host "(fastfetch failed to start: $($_.Exception.Message))" -ForegroundColor DarkYellow @@ -190,12 +159,16 @@ if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { # Function/alias/cmdlet: no reliable way to cancel cooperatively, # so just invoke it directly. fastfetch + $script:_fastfetchShown = $true } } } -# Welcome message (only in interactive sessions) -if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { +# Welcome message (only in interactive sessions). fastfetch already reports the +# shell and everything else worth knowing, so when its banner rendered these two +# lines are pure noise and are skipped. A light install without fastfetch keeps +# them, so an interactive shell still confirms the profile loaded. +if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR -and -not $script:_fastfetchShown) { $script:_profileLoadStart.Stop() $loadTimeMs = $script:_profileLoadStart.ElapsedMilliseconds Write-Host "[OK] PowerShell Profile Loaded ($loadTimeMs ms)" -ForegroundColor Green diff --git a/tests/bash/fastfetch-status.bats b/tests/bash/fastfetch-status.bats index 205c16f8..68a0aeef 100644 --- a/tests/bash/fastfetch-status.bats +++ b/tests/bash/fastfetch-status.bats @@ -118,6 +118,24 @@ EOF [[ "$output" =~ "2 update(s) available (apt)" ]] } +@test "fastfetch-status: updates line reports when it was calculated" { + _mock apt-get <<'EOF' +#!/bin/bash +echo 'Inst bash [5.2] (5.2.15 Debian:13 [arm64])' +EOF + run bash "${SCRIPT}" refresh + [ "$status" -eq 0 ] + + # Stored as an absolute epoch token so the age keeps advancing between + # refreshes rather than freezing at "checked 0s ago". + run cat "${XDG_CACHE_HOME}/fastfetch-status/updates" + [[ "$output" =~ checked\ @ago:[0-9]+@ ]] + + run bash "${SCRIPT}" updates + [ "$status" -eq 0 ] + [[ "$output" =~ "checked 0s ago" ]] +} + @test "fastfetch-status: no apt updates leaves updates section empty" { _mock apt-get <<'EOF' #!/bin/bash @@ -174,9 +192,10 @@ EOF [ "$status" -eq 0 ] # The cache stores absolute epoch tokens, not pre-rendered durations. run cat "${XDG_CACHE_HOME}/fastfetch-status/ansible" - [[ "$output" =~ @ago:[0-9]+@ ]] - [[ "$output" =~ @in:[0-9]+@ ]] - [[ ! "$output" =~ "ran " ]] + [[ "$output" =~ ran\ @ago:[0-9]+@ ]] + [[ "$output" =~ next\ @in:[0-9]+@ ]] + # ... and definitely not a duration that would freeze until the next refresh. + [[ ! "$output" =~ [0-9]+[smhd]\ ago ]] # Emitting expands them against the current clock. run bash "${SCRIPT}" ansible [ "$status" -eq 0 ] @@ -188,7 +207,7 @@ EOF @test "fastfetch-status: relative times advance without a cache refresh" { mkdir -p "${XDG_CACHE_HOME}/fastfetch-status" : >"${XDG_CACHE_HOME}/fastfetch-status/.refreshed-at" - printf 'ansible-pull OK \302\267 @ago:%s@\n' "$(($(date +%s) - 3540))" \ + printf 'ansible-pull OK \302\267 ran @ago:%s@\n' "$(($(date +%s) - 3540))" \ >"${XDG_CACHE_HOME}/fastfetch-status/ansible" run bash "${SCRIPT}" ansible @@ -196,7 +215,7 @@ EOF [[ "$output" =~ "ran 59m ago" ]] # Same cache file, later clock -> a different rendered duration. - printf 'ansible-pull OK \302\267 @ago:%s@\n' "$(($(date +%s) - 3660))" \ + printf 'ansible-pull OK \302\267 ran @ago:%s@\n' "$(($(date +%s) - 3660))" \ >"${XDG_CACHE_HOME}/fastfetch-status/ansible" run bash "${SCRIPT}" ansible [ "$status" -eq 0 ] @@ -206,7 +225,7 @@ EOF @test "fastfetch-status: elapsed next-run token renders as due" { mkdir -p "${XDG_CACHE_HOME}/fastfetch-status" : >"${XDG_CACHE_HOME}/fastfetch-status/.refreshed-at" - printf 'ansible-pull OK \302\267 @in:%s@\n' "$(($(date +%s) - 60))" \ + printf 'ansible-pull OK \302\267 next @in:%s@\n' "$(($(date +%s) - 60))" \ >"${XDG_CACHE_HOME}/fastfetch-status/ansible" run bash "${SCRIPT}" ansible @@ -214,6 +233,22 @@ EOF [[ "$output" =~ "next due" ]] } +@test "fastfetch-status: tokens expand to a bare duration phrase" { + # The wording around a token belongs to the collector, so the same token + # can read as "checked 5m ago" or "ran 5m ago" depending on the section. + mkdir -p "${XDG_CACHE_HOME}/fastfetch-status" + : >"${XDG_CACHE_HOME}/fastfetch-status/.refreshed-at" + printf 'checked @ago:%s@ \302\267 due @in:%s@\n' \ + "$(($(date +%s) - 300))" "$(($(date +%s) + 600))" \ + >"${XDG_CACHE_HOME}/fastfetch-status/updates" + + run bash "${SCRIPT}" updates + [ "$status" -eq 0 ] + [[ "$output" =~ "checked 5m ago" ]] + [[ "$output" =~ "due in 10m" ]] + [[ ! "$output" =~ "ran " ]] +} + @test "fastfetch-status: lines without tokens are emitted verbatim" { mkdir -p "${XDG_CACHE_HOME}/fastfetch-status" : >"${XDG_CACHE_HOME}/fastfetch-status/.refreshed-at" diff --git a/tests/powershell/FastfetchStatus.Tests.ps1 b/tests/powershell/FastfetchStatus.Tests.ps1 index 77b9b7de..d0830fa0 100644 --- a/tests/powershell/FastfetchStatus.Tests.ps1 +++ b/tests/powershell/FastfetchStatus.Tests.ps1 @@ -206,16 +206,35 @@ Describe "fastfetch status.ps1 update line" { It "should report the count and the manager" { Mock Get-WingetUpdateCount { 6 } - Get-UpdatesLine | Should -Match '^\S+ 6 update\(s\) available \(winget\)$' + Get-UpdatesLine | Should -Match '^\S+ 6 update\(s\) available \(winget\) \S+ checked @ago:\d+@$' } - It "refresh should write a cache file and a stamp" { + It "should date the line with a token rather than a frozen duration" { + # A pre-rendered "checked 0s ago" would stay 0s until the next refresh + # an hour later, so the epoch has to survive into the cache. + Mock Get-WingetUpdateCount { 6 } + $line = Get-UpdatesLine + $line | Should -Not -Match '\d+[smhd] ago' + $line | Should -Match '@ago:\d+@' + } + + It "refresh should write a source file and a stamp" { Mock Get-WingetUpdateCount { 4 } Invoke-StatusRefresh Join-Path $script:TestCacheDir '.refreshed-at' | Should -Exist + $line = Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Raw + $line | Should -Match '4 update\(s\) available \(winget\)' + } + + It "refresh should render the source into the file fastfetch reads" { + Mock Get-WingetUpdateCount { 4 } + Invoke-StatusRefresh + $line = Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates') -Raw $line | Should -Match '4 update\(s\) available \(winget\)' + $line | Should -Match 'checked \d+[smhd] ago' + $line | Should -Not -Match '@ago:' } It "refresh should write the cache as UTF-8 without a BOM so cmd /c type stays clean" { @@ -226,7 +245,7 @@ Describe "fastfetch status.ps1 update line" { $bytes[0] | Should -Not -Be 0xEF } - It "refresh should remove the cache file when nothing is outdated" { + It "refresh should remove both cache files when nothing is outdated" { Mock Get-WingetUpdateCount { 3 } Invoke-StatusRefresh Join-Path $script:TestCacheDir 'updates' | Should -Exist @@ -234,6 +253,7 @@ Describe "fastfetch status.ps1 update line" { Mock Get-WingetUpdateCount { 0 } Invoke-StatusRefresh Join-Path $script:TestCacheDir 'updates' | Should -Not -Exist + Join-Path $script:TestCacheDir 'updates.src' | Should -Not -Exist } It "refresh should release the lock on completion" { @@ -313,17 +333,179 @@ Describe "fastfetch chezmoi wiring" { $script:IgnoreContent | Should -Not -Match '\.config/fastfetch/\*\*' } - It "profile should spawn a background refresh of the status cache" { - $script:ProfileContent | Should -Match 'fastfetch\\status\.ps1' - $script:ProfileContent | Should -Match "'refresh'|refresh" + It "profile should delegate the status cache to the module" { + # The render must run on every shell start (so "checked 5m ago" keeps + # counting), but dot-sourcing status.ps1 from the profile costs ~60ms, + # hence the module function the profile already has loaded. + $script:ProfileContent | Should -Match 'Update-FastfetchStatusCache' + } + + It "profile should not inline the refresh spawn any more" { + $script:ProfileContent | Should -Not -Match 'CreateNoWindow' + $script:ProfileContent | Should -Not -Match 'FASTFETCH_STATUS_TTL' + } + + It "profile should skip the welcome lines when fastfetch rendered" { + $script:ProfileContent | Should -Match '\$script:_fastfetchShown = \$true' + $script:ProfileContent | Should -Match '-not \$script:_fastfetchShown' + } + + It "profile should still show the welcome lines without fastfetch" { + # The welcome block must not be nested inside the fastfetch guard, or a + # light install would start completely silently. + $script:ProfileContent | Should -Match 'PowerShell Profile Loaded' } +} + +Describe "fastfetch status module helpers" { + BeforeAll { + Import-Module (Join-Path $script:RepoRoot "home\dot_config\powershell\modules\DotfilesHelpers") ` + -Force -DisableNameChecking + } + + Context "Format-FastfetchStatusDuration" { + It "should format s as ''" -ForEach @( + @{ Seconds = 0; Expected = '0s' } + @{ Seconds = 45; Expected = '45s' } + @{ Seconds = 59; Expected = '59s' } + @{ Seconds = 60; Expected = '1m' } + @{ Seconds = 3540; Expected = '59m' } + @{ Seconds = 3600; Expected = '1h' } + @{ Seconds = 86399; Expected = '23h' } + @{ Seconds = 86400; Expected = '1d' } + ) { + Format-FastfetchStatusDuration -Seconds $seconds | Should -Be $expected + } - It "profile background refresh should not create a visible window" { - $script:ProfileContent | Should -Match 'CreateNoWindow' + It "should clamp a negative age to zero (clock skew)" { + Format-FastfetchStatusDuration -Seconds -30 | Should -Be '0s' + } } - It "profile should honour FASTFETCH_STATUS_DISABLE" { - $script:ProfileContent | Should -Match 'FASTFETCH_STATUS_DISABLE' + Context "Expand-FastfetchStatusToken" { + BeforeAll { $script:Now = 1700000000 } + + It "should expand an elapsed token to a bare duration phrase" { + Expand-FastfetchStatusToken -Line "checked @ago:$($script:Now - 300)@" -Now $script:Now | + Should -Be 'checked 5m ago' + } + + It "should let the caller own the wording around a token" { + Expand-FastfetchStatusToken -Line "ran @ago:$($script:Now - 300)@" -Now $script:Now | + Should -Be 'ran 5m ago' + } + + It "should expand a future token" { + Expand-FastfetchStatusToken -Line "next @in:$($script:Now + 1200)@" -Now $script:Now | + Should -Be 'next in 20m' + } + + It "should report a passed future token as due" { + Expand-FastfetchStatusToken -Line "next @in:$($script:Now - 60)@" -Now $script:Now | + Should -Be 'next due' + } + + It "should expand several tokens in one line" { + $line = "OK - ran @ago:$($script:Now - 60)@, next @in:$($script:Now + 60)@" + Expand-FastfetchStatusToken -Line $line -Now $script:Now | + Should -Be 'OK - ran 1m ago, next in 1m' + } + + It "should leave unknown tokens untouched rather than throwing" { + Expand-FastfetchStatusToken -Line 'user@host @nope:1@ 100% done' -Now $script:Now | + Should -Be 'user@host @nope:1@ 100% done' + } + + It "should handle an empty line" { + Expand-FastfetchStatusToken -Line '' -Now $script:Now | Should -Be '' + } + } + + Context "Update-FastfetchStatusCache" { + BeforeEach { + if (Test-Path -LiteralPath $script:TestCacheDir) { + Remove-Item -LiteralPath $script:TestCacheDir -Recurse -Force + } + New-Item -ItemType Directory -Path $script:TestCacheDir -Force | Out-Null + # Fresh stamp so no background refresh is triggered by these tests. + Set-Content -LiteralPath (Join-Path $script:TestCacheDir '.refreshed-at') -Value '' + } + + It "should render a source file into its sibling" { + $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 600 + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') ` + -Value "6 update(s) available (winget) - checked @ago:$epoch@" + + Update-FastfetchStatusCache -SkipRefresh + + $rendered = Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates') -Raw + $rendered | Should -Match 'checked 10m ago' + } + + It "should re-render with the current clock, not the cached duration" { + $src = Join-Path $script:TestCacheDir 'updates.src' + $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 60 + Set-Content -LiteralPath $src -Value "checked @ago:$epoch@" + Update-FastfetchStatusCache -SkipRefresh + (Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates') -Raw) | + Should -Match 'checked 1m ago' + + # Same source, older timestamp -> a different rendered duration, + # without any refresh having run. + $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 7200 + Set-Content -LiteralPath $src -Value "checked @ago:$epoch@" + Update-FastfetchStatusCache -SkipRefresh + (Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates') -Raw) | + Should -Match 'checked 2h ago' + } + + It "should write UTF-8 without a BOM" { + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'plain line' + Update-FastfetchStatusCache -SkipRefresh + + $bytes = [System.IO.File]::ReadAllBytes((Join-Path $script:TestCacheDir 'updates')) + $bytes[0] | Should -Not -Be 0xEF + } + + It "should drop a rendered file whose source has gone" { + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'line' + Update-FastfetchStatusCache -SkipRefresh + Join-Path $script:TestCacheDir 'updates' | Should -Exist + + Remove-Item -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') + Update-FastfetchStatusCache -SkipRefresh + Join-Path $script:TestCacheDir 'updates' | Should -Not -Exist + } + + It "should leave the stamp alone" { + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'line' + Update-FastfetchStatusCache -SkipRefresh + Join-Path $script:TestCacheDir '.refreshed-at' | Should -Exist + } + + It "should do nothing when disabled" { + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'line' + $env:FASTFETCH_STATUS_DISABLE = '1' + try { + Update-FastfetchStatusCache -SkipRefresh + Join-Path $script:TestCacheDir 'updates' | Should -Not -Exist + } + finally { + $env:FASTFETCH_STATUS_DISABLE = $script:OriginalDisable + } + } + + It "should not throw when the cache directory does not exist" { + Remove-Item -LiteralPath $script:TestCacheDir -Recurse -Force + { Update-FastfetchStatusCache -SkipRefresh } | Should -Not -Throw + } + } + + Context "Start-FastfetchStatusRefresh" { + It "should do nothing when the status script is missing" { + $missing = Join-Path $script:TestCacheDir 'no-such-status.ps1' + { Start-FastfetchStatusRefresh -StatusScript $missing } | Should -Not -Throw + } } } diff --git a/tests/powershell/Profile.Tests.ps1 b/tests/powershell/Profile.Tests.ps1 index 9f3d4496..86a1fcff 100644 --- a/tests/powershell/Profile.Tests.ps1 +++ b/tests/powershell/Profile.Tests.ps1 @@ -454,6 +454,42 @@ Describe "Profile fastfetch startup guard" { # light install (no fastfetch) from erroring on every shell start. $script:ProfileContent | Should -Match 'Get-Command fastfetch -ErrorAction SilentlyContinue' } + + It "Profile should refresh the fastfetch status cache via the module" { + $script:ProfileContent | Should -Match 'Update-FastfetchStatusCache' + } + + It "Profile should guard the status call so a broken cache cannot break the shell" { + $script:ProfileContent | Should -Match 'Get-Command Update-FastfetchStatusCache -ErrorAction SilentlyContinue' + } +} + +Describe "Profile startup noise" { + BeforeAll { + $script:ProfileContent = Get-Content $script:ProfilePath -Raw + } + + It "Profile should record whether fastfetch rendered" { + $script:ProfileContent | Should -Match '\$script:_fastfetchShown = \$false' + $script:ProfileContent | Should -Match '\$script:_fastfetchShown = \$true' + } + + It "Profile should skip the welcome lines when fastfetch rendered" { + # fastfetch already reports the shell and everything else worth knowing, + # so the two extra lines are pure noise underneath its banner. + $script:ProfileContent | Should -Match '-not \$script:_fastfetchShown' + } + + It "Profile should not mark fastfetch as shown when it timed out" { + # A killed fastfetch prints a notice instead of a banner, so the welcome + # lines are still the only confirmation the profile loaded. + $script:ProfileContent | Should -Match 'if \(-not \$fastfetchProc\.WaitForExit' + } + + It "Profile should keep the welcome lines for installs without fastfetch" { + $script:ProfileContent | Should -Match 'PowerShell Profile Loaded' + $script:ProfileContent | Should -Match "Type 'aliases' to see available aliases" + } } Describe "Profile Syntax Validation" { diff --git a/tests/powershell/WindowsTerminal.Tests.ps1 b/tests/powershell/WindowsTerminal.Tests.ps1 index 0a263350..8cbad4d6 100644 --- a/tests/powershell/WindowsTerminal.Tests.ps1 +++ b/tests/powershell/WindowsTerminal.Tests.ps1 @@ -343,6 +343,147 @@ Describe "Set-WindowsTerminalDefaultProfile Function" -Tag "Unit" { } } +Describe "Set-WindowsTerminalProfileCommandLine Function" -Tag "Unit" { + BeforeEach { + $script:settingsPath = Join-Path $script:TestDir.FullName "settings-$(Get-Random).json" + } + + AfterEach { + Remove-Item -LiteralPath $script:settingsPath -ErrorAction SilentlyContinue + } + + BeforeAll { + $script:QuietCommandLine = 'pwsh.exe -NoLogo -NoProfileLoadTime' + $script:CmdLineSettings = @' +{ + "defaultProfile": "{574e775e-4f2a-5b96-ac1e-a2962a402336}", + "profiles": { + "defaults": { "font": { "face": "FiraCode Nerd Font" } }, + "list": [ + { + "commandline": "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "guid": "{61c54bbd-c2c6-5271-96e7-009a87ff44bf}", + "name": "Windows PowerShell" + }, + { + "guid": "{574e775e-4f2a-5b96-ac1e-a2962a402336}", + "name": "PowerShell", + "source": "Windows.Terminal.PowershellCore" + }, + { + "guid": "{36f9ac1f-0a96-55ed-952d-57a0df08d14f}", + "name": "Debian", + "source": "Microsoft.WSL" + } + ] + } +} +'@ + } + + It "Should support ShouldProcess (WhatIf)" { + (Get-Command Set-WindowsTerminalProfileCommandLine).Parameters.ContainsKey("WhatIf") | Should -Be $true + } + + It "Should add a commandline to the PowerShell Core profile" { + # The dynamic PowerShell profile has no commandline of its own, so this + # is how -NoLogo/-NoProfileLoadTime get applied: they are read before + # any profile script runs and cannot be set from profile.ps1. + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'Updated' + $result.MatchedProfile | Should -Be 'PowerShell' + + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $core = @($json.profiles.list) | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' } + $core.commandline | Should -Be $script:QuietCommandLine + } + + It "Should leave the other profiles untouched" { + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath | Out-Null + + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $json.profiles.list.Count | Should -Be 3 + @($json.profiles.list)[0].commandline | Should -Be '%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe' + $json.profiles.defaults.font.face | Should -Be 'FiraCode Nerd Font' + $json.defaultProfile | Should -Be '{574e775e-4f2a-5b96-ac1e-a2962a402336}' + } + + It "Should replace an existing commandline" { + $existing = $script:CmdLineSettings -replace '"name": "PowerShell",', '"name": "PowerShell", "commandline": "pwsh.exe",' + $existing | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'Updated' + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $core = @($json.profiles.list) | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' } + $core.commandline | Should -Be $script:QuietCommandLine + } + + It "Should be idempotent when the commandline already matches" { + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath | Out-Null + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'AlreadySet' + $result.Changed | Should -Be $false + } + + It "Should match a profile by name via -ProfileName" { + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -ProfileName 'Debian' -CommandLine 'wsl -d Debian' -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'Updated' + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $debian = @($json.profiles.list) | Where-Object { $_.name -eq 'Debian' } + $debian.commandline | Should -Be 'wsl -d Debian' + } + + It "Should skip (ProfileNotFound) when PowerShell Core is not installed" { + $noCore = @' +{ + "profiles": { + "list": [ + { "guid": "{61c54bbd-c2c6-5271-96e7-009a87ff44bf}", "name": "Windows PowerShell" } + ] + } +} +'@ + $noCore | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'ProfileNotFound' + $result.Changed | Should -Be $false + } + + It "Should skip paths that do not exist" { + $missing = Join-Path $script:TestDir.FullName "does-not-exist-$(Get-Random).json" + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $missing + + $result | Should -BeNullOrEmpty + } + + It "Should not write under -WhatIf" { + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath -WhatIf + + $result.Status | Should -Be 'WhatIf' + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $core = @($json.profiles.list) | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' } + $core.PSObject.Properties['commandline'] | Should -BeNullOrEmpty + } +} + Describe "Set-WindowsTerminalCopilotProfile Function" -Tag "Unit" { BeforeEach { $script:settingsPath = Join-Path $script:TestDir.FullName "settings-$(Get-Random).json"