Private/Get-Win32ToolkitUpdateInfo.ps1
|
function Get-Win32ToolkitUpdateInfo { <# .SYNOPSIS Non-nagging, fail-open check for a newer win32-toolkit release on GitHub. .DESCRIPTION Returns update status for the TUI to surface, or $null when the check is opted out, throttled with no cached answer, or fails for ANY reason. It is designed to never break or slow the toolkit: - opt-out is honoured BEFORE any network call (env vars, CI, non-interactive host, config) - the GitHub Releases API is queried at most once per TTL (cached under LOCALAPPDATA) - every network/parse error is swallowed to Write-Verbose and returns $null GitHub Releases (not the PowerShell Gallery) is the source: it lights up the instant a release-please release PR merges, needs no auth, and matches how this project ships. The version comparison uses the PS7 [semver] type and therefore stays HOST-SIDE only; it must never be spliced into the 5.1-safe device scripts the toolkit generates. .PARAMETER TtlHours Minimum hours between live network checks. Within the window the cached answer is reused. .PARAMETER Force Ignore the cache TTL and check now (used by the Settings 'check now' action). .OUTPUTS PSCustomObject { Installed, Latest, UpdateAvailable, Source, ReleasesUrl } or $null. #> [CmdletBinding()] [OutputType([object])] param( [int]$TtlHours = 24, [switch]$Force ) # ── opt-out, checked before any work ──────────────────────────────────────────────────────── # WIN32TOOLKIT_NO_UPDATE_CHECK is ours; DO_NOT_TRACK / NO_UPDATE_NOTIFIER are community standards; # CI and a redirected/non-interactive host mean "not a person looking at a menu". if ($env:WIN32TOOLKIT_NO_UPDATE_CHECK) { Write-Verbose 'Update check: opted out (WIN32TOOLKIT_NO_UPDATE_CHECK).'; return $null } if ($env:DO_NOT_TRACK) { Write-Verbose 'Update check: opted out (DO_NOT_TRACK).'; return $null } if ($env:NO_UPDATE_NOTIFIER) { Write-Verbose 'Update check: opted out (NO_UPDATE_NOTIFIER).'; return $null } if ($env:CI) { Write-Verbose 'Update check: skipped under CI.'; return $null } if ((Get-Win32ToolkitConfigValue -Name 'UpdateCheck' -Default 'On') -eq 'Off') { Write-Verbose 'Update check: disabled in Settings.'; return $null } # ── installed version ─────────────────────────────────────────────────────────────────────── $installed = Get-Win32ToolkitVersion if ([string]::IsNullOrWhiteSpace($installed)) { return $null } $repo = 'MG-Cloudflow/win32-toolkit' $releasesUrl = "https://github.com/$repo/releases" # ── cache (one file, mirrors the HKCU:\Software\CloudFlow\win32-toolkit namespace) ────────── # Guard LOCALAPPDATA: a null/empty value would make Join-Path throw, breaking the never-throws # contract. If it is unset we simply run without a cache (live check each time) rather than crash. $cacheDir = $null $cacheFile = $null if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { $cacheDir = Join-Path $env:LOCALAPPDATA 'CloudFlow\win32-toolkit' $cacheFile = Join-Path $cacheDir 'update-check.json' } $cache = $null try { if ($cacheFile -and (Test-Path $cacheFile)) { $cache = Get-Content $cacheFile -Raw -Encoding UTF8 | ConvertFrom-Json } } catch { } $latest = $null $fresh = $false if (-not $Force -and $cache -and $cache.lastCheck) { try { $last = [datetime]::Parse($cache.lastCheck, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) if (((Get-Date).ToUniversalTime() - $last.ToUniversalTime()).TotalHours -lt $TtlHours) { $fresh = $true $latest = $cache.latestVersion } } catch { } } # ── fetch (only when stale), fail open ────────────────────────────────────────────────────── if (-not $fresh) { $fetched = $null try { # GitHub rejects requests with no User-Agent; 3s timeout keeps a slow network from stalling # the menu. GitHub's 'latest' is already the newest non-prerelease/non-draft release. $resp = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" ` -Headers @{ 'User-Agent' = "win32-toolkit/$installed"; 'Accept' = 'application/vnd.github+json' } ` -TimeoutSec 3 -ErrorAction Stop if ($resp.tag_name) { $fetched = ([string]$resp.tag_name).TrimStart('v', 'V') } } catch { Write-Verbose "Update check: fetch failed ($($_.Exception.Message)); failing open." } if ($fetched) { $latest = $fetched } # Rewrite lastCheck on BOTH success and failure so an outage backs off for the TTL instead of # retrying (and slowing the menu) on every launch. Skipped entirely when there is no cache dir. if ($cacheFile) { try { if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } [pscustomobject]@{ lastCheck = (Get-Date).ToUniversalTime().ToString('o') latestVersion = $latest source = 'github' } | ConvertTo-Json | Set-Content -Path $cacheFile -Encoding UTF8 } catch { } } } if (-not $latest) { return $null } # ── compare (PS7 [semver], host-side only) ────────────────────────────────────────────────── $updateAvailable = $false try { $li = $null; $lv = $null if ([System.Management.Automation.SemanticVersion]::TryParse("$installed", [ref]$li) -and [System.Management.Automation.SemanticVersion]::TryParse("$latest", [ref]$lv)) { $updateAvailable = $lv -gt $li } else { $updateAvailable = ([version]"$latest") -gt ([version]"$installed") } } catch { return $null } [pscustomobject]@{ Installed = "$installed" Latest = "$latest" UpdateAvailable = [bool]$updateAvailable Source = 'github' ReleasesUrl = $releasesUrl } } |