Update-UsageMonitor.ps1
|
<#PSScriptInfo
.VERSION 1.1.1 .GUID be86f5c3-bbbf-44ad-8fb1-4b16b4faafef .AUTHOR hybrid2102 .COMPANYNAME .COPYRIGHT (c) 2026 hybrid2102. MIT licensed. .TAGS Windows Updater GitHub Claude UsageMonitor Tray AutoUpdate .LICENSEURI https://github.com/hybrid2102/usage-monitor-updater/blob/main/LICENSE .PROJECTURI https://github.com/hybrid2102/usage-monitor-updater .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES https://github.com/hybrid2102/usage-monitor-updater/blob/main/CHANGELOG.md .PRIVATEDATA #> #Requires -Version 5.1 <# .SYNOPSIS Updates UsageMonitorForClaude.exe to the latest GitHub release and restarts it. .DESCRIPTION Updates Usage Monitor for Claude to its latest GitHub release in one step, and shows the release notes while the download is still running - the progress indicator is drawn below the text instead of over it, so waiting time is reading time. The downloaded build is verified against the SHA-256 digest GitHub publishes for it and against its Authenticode signature; nothing is installed if verification fails. The new file is staged beside the target so the final swap is atomic, and the previous build is kept until that swap succeeds. Steps: 1. Reads the latest release of the given GitHub repository. 2. Compares it with the version of the installed executable. If they match, asks whether to re-download and re-apply it anyway. 3. Prints the release notes (changelog) of the new version. 4. Closes every running instance of the executable. 5. Downloads the matching asset for the current CPU architecture, verifies it (SHA-256 digest published by GitHub and/or Authenticode signature), then moves it over the installed copy. 6. Restarts the main instance plus any additional instances defined by "<exe-name> - *.lnk" shortcuts sitting next to the executable. The changelog stays readable while the download runs: the progress indicator is a single line redrawn in place below the text, instead of Write-Progress, which Windows PowerShell renders as a banner over the top of the console. .PARAMETER Repo GitHub repository in "owner/name" form. Defaults to the Usage Monitor for Claude repository. .PARAMETER InstallDir Folder holding the executable. Defaults to the folder containing this script, which is the right answer when the script sits next to the executable. Installed from the PowerShell Gallery the script lives in a shared scripts folder instead, so -InstallDir is required there. That case is detected and reported rather than silently searching a system folder for an executable that is not in it. .PARAMETER ExeName File name of the executable to update. Defaults to UsageMonitorForClaude.exe. .PARAMETER Force Reinstall even when the installed version already matches the latest release, without asking for confirmation. .PARAMETER SkipChangelog Do not print the release notes. .PARAMETER NonInteractive Never prompt. Questions resolve to their default answer (usually "no"), which makes the script safe to run from Task Scheduler or CI. .PARAMETER SkipSignatureCheck Skip the Authenticode signature check on the downloaded file. The SHA-256 digest check still runs. Rarely needed: an unsigned file whose digest matches is already accepted without prompting. .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File .\Update-UsageMonitor.ps1 .EXAMPLE .\Update-UsageMonitor.ps1 -InstallDir 'C:\Tools\UsageMonitor' -NonInteractive .NOTES Windows only. Requires Windows PowerShell 5.1 or PowerShell 7+ on Windows. Exit codes: 0 success (updated, or already up to date and nothing to do) 1 unexpected error 2 unsupported platform or bad configuration 3 network / GitHub API problem (including rate limiting) 4 integrity verification of the downloaded file failed 5 the installed file could not be replaced (locked or not writable) This script is not affiliated with the author of Usage Monitor for Claude, nor with Anthropic. See README.md. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [ValidatePattern('^[\w.-]+/[\w.-]+$')] [string] $Repo = 'jens-duttke/usage-monitor-for-claude', [string] $InstallDir, [ValidatePattern('\.exe$')] [string] $ExeName = 'UsageMonitorForClaude.exe', [switch] $Force, [switch] $SkipChangelog, [switch] $NonInteractive, [switch] $SkipSignatureCheck ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # keep the built-in banner off the changelog #region ------------------------------------------------------------- constants # Exit codes, also documented in the comment-based help above. $Script:ExitOk = 0 $Script:ExitError = 1 $Script:ExitPlatform = 2 $Script:ExitNetwork = 3 $Script:ExitVerification = 4 $Script:ExitReplace = 5 $Script:UserAgent = 'update-usage-monitor-script' $Script:ApiRoot = 'https://api.github.com' # Progress rendering state, initialised in Invoke-Update. $Script:CanRedraw = $false $Script:LastLoggedDecile = -1 #endregion #region -------------------------------------------------------------------- ui function Write-Rule { param([string] $Title) $width = 64 if ($Title) { $line = "-- $Title " + ('-' * [math]::Max(0, $width - $Title.Length - 4)) } else { $line = '-' * $width } Write-Host $line -ForegroundColor DarkGray } <# One Write-Host per line on purpose: splitting the prefix from the message with -NoNewline puts them on separate lines as soon as the output is redirected to a file or a CI log. #> function Write-Step { param([Parameter(Mandatory)][string] $Message) Write-Host "==> $Message" -ForegroundColor Cyan } function Write-Detail { param( [Parameter(Mandatory)][string] $Message, [ConsoleColor] $Color = [ConsoleColor]::DarkGray ) Write-Host " $Message" -ForegroundColor $Color } function Write-Ok { param([string] $Message) Write-Host "[ok] $Message" -ForegroundColor Green } function Write-Warn { param([string] $Message) Write-Host "[!] $Message" -ForegroundColor Yellow } function Write-Fail { param([string] $Message) Write-Host "[x] $Message" -ForegroundColor Red } function Format-Size { param([Parameter(Mandatory)][double] $Bytes) if ($Bytes -ge 1MB) { return '{0:N1} MB' -f ($Bytes / 1MB) } if ($Bytes -ge 1KB) { return '{0:N0} KB' -f ($Bytes / 1KB) } return "$([int] $Bytes) B" } <# Yes/no prompt. With -NonInteractive, or when no console is attached, it answers with the default instead of blocking forever (Task Scheduler, CI, ...). #> function Read-YesNo { param( [Parameter(Mandatory)][string] $Question, [switch] $DefaultYes ) $default = [bool] $DefaultYes $suffix = if ($default) { '[Y/n]' } else { '[y/N]' } if ($NonInteractive -or -not [Environment]::UserInteractive) { Write-Warn "$Question $suffix -> non-interactive session, assuming '$(if ($default) { 'yes' } else { 'no' })'." return $default } while ($true) { Write-Host "? $Question $suffix " -ForegroundColor Yellow -NoNewline $answer = (Read-Host).Trim().ToLowerInvariant() if ([string]::IsNullOrEmpty($answer)) { return $default } if ($answer -in @('y', 'yes')) { return $true } if ($answer -in @('n', 'no')) { return $false } Write-Detail "Please answer 'y' or 'n'." ([ConsoleColor]::DarkYellow) } } <# Turns markdown links into their visible text: "[Full changelog](https://...)" reads as "Full changelog". Release notes are written for a web page, where the URL is hidden behind the text; printed verbatim in a terminal the URL crowds the line out and usually gets truncated, so the reader loses the sentence and gains nothing. Bare URLs are left alone - there the address IS the information. #> function Convert-MarkdownLink { param([Parameter(Mandatory)][AllowEmptyString()][string] $Text) # Images first: their alt text is what is worth keeping. $result = [regex]::Replace($Text, '!\[([^\]]*)\]\([^)]*\)', '$1') # Then ordinary links. URLs containing brackets are rare enough not to chase. return [regex]::Replace($result, '\[([^\]]+)\]\([^)]*\)', '$1') } <# Prints the release notes with minimal markdown rendering. Called BEFORE the download starts; the progress indicator below is a single line redrawn in place, so this text stays on screen and readable for the whole download. #> function Show-Changelog { param([Parameter(Mandatory)] $Release) Write-Host '' Write-Rule "What's new in $($Release.tag_name)" if ([string]::IsNullOrWhiteSpace($Release.body)) { Write-Detail 'No release notes were published for this version.' } else { $inCodeBlock = $false foreach ($raw in ($Release.body -split "`r?`n")) { $line = $raw.TrimEnd() if ($line -match '^\s*```') { $inCodeBlock = -not $inCodeBlock continue } if ($inCodeBlock) { # Code is shown verbatim: rewriting anything inside it would be a lie # about what the release notes say to type. Write-Host " $($line.TrimEnd())" -ForegroundColor DarkGreen continue } $line = Convert-MarkdownLink -Text $line switch -Regex ($line) { '^\s*#{1,6}\s*(.+)$' { Write-Host '' Write-Host " $($Matches[1].Trim())" -ForegroundColor Cyan break } '^\s*[-*+]\s+(.+)$' { Write-Host " - $($Matches[1].Trim())" break } '^\s*$' { Write-Host '' break } default { Write-Host " $($line.Trim())" } } } } if ($Release.html_url) { Write-Host '' Write-Detail "Full release page: $($Release.html_url)" } Write-Rule Write-Host '' } <# True when the output is a real console we can redraw a line on. Redirected output (a log file, a pipeline, a CI job) has no cursor to move, so a carriage return would just concatenate every frame into one enormous line. #> function Test-CanRedraw { if ($Host.Name -ne 'ConsoleHost') { return $false } try { return -not [Console]::IsOutputRedirected } catch { return $false } } <# Single-line progress indicator, redrawn with a carriage return. Deliberately not Write-Progress: in Windows PowerShell that paints a banner across the top of the console and would hide the changelog printed just above. When the output is redirected it degrades to one plain line every 10%, so logs stay readable instead of collecting a hundred overwritten frames. #> function Write-ProgressBar { param( [Parameter(Mandatory)][long] $Current, [long] $Total, [int] $Width = 34, [switch] $Final ) if ($Total -gt 0) { $ratio = [math]::Min(1.0, $Current / $Total) $filled = [int][math]::Round($Width * $ratio) $bar = ('#' * $filled).PadRight($Width, '.') $text = ' [{0}] {1,3:N0}% {2} / {3}' -f $bar, ($ratio * 100), (Format-Size $Current), (Format-Size $Total) } else { # No Content-Length header: show downloaded bytes only. $ratio = 0 $text = ' [{0}] {1} downloaded' -f ('.' * $Width), (Format-Size $Current) } if ($Script:CanRedraw) { Write-Host "`r$($text.PadRight(76))" -NoNewline -ForegroundColor DarkCyan return } # Redirected output: one line per decile, plus the final state. $decile = [int]([math]::Floor($ratio * 10)) if ($Final -or $decile -gt $Script:LastLoggedDecile) { $Script:LastLoggedDecile = $decile Write-Host $text -ForegroundColor DarkCyan } } #endregion #region --------------------------------------------------------- environment function Test-Windows { # $IsWindows only exists in PowerShell 6+; 5.1 is Windows-only by definition. if ($PSVersionTable.PSVersion.Major -lt 6) { return $true } return [bool] $IsWindows } function Initialize-SecurityProtocol { # Windows PowerShell defaults to SSL3/TLS1.0 on older systems, which GitHub rejects. try { $wanted = [Net.SecurityProtocolType]::Tls12 if ([enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') { $wanted = $wanted -bor [Net.SecurityProtocolType]::Tls13 } [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor $wanted } catch { Write-Warn "Could not raise the TLS version: $($_.Exception.Message)" } } function Test-DirectoryWritable { param([Parameter(Mandatory)][string] $Path) $probe = Join-Path $Path ".write-test-$([guid]::NewGuid().ToString('N')).tmp" try { [System.IO.File]::WriteAllText($probe, 'x') Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue -WhatIf:$false return $true } catch { return $false } } <# True when the script is running from a PowerShell Gallery scripts folder rather than from beside the application. Install-Script puts scripts under <Documents>\WindowsPowerShell\Scripts, <Documents>\PowerShell\Scripts, or the machine-wide equivalent under Program Files - none of which is an install folder for the application being updated. #> function Test-GalleryInstall { param([Parameter(Mandatory)][string] $Path) $normalised = $Path.TrimEnd('\', '/') if ($normalised -notmatch '[\\/]Scripts$') { return $false } $parent = Split-Path -Parent $normalised return ($parent -match '[\\/](WindowsPowerShell|PowerShell)$') } function Test-Elevated { try { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } #endregion #region ------------------------------------------------------------- releases <# Normalises the two different failures Invoke-RestMethod can raise, because they are not interchangeable: - Windows PowerShell 5.1 throws System.Net.WebException, whose Response is an HttpWebResponse with a string-indexed header collection; - PowerShell 6+ throws Microsoft.PowerShell.Commands.HttpResponseException, whose Response is an HttpResponseMessage with a TryGetValues-based header collection. Catching only one of them silently disables every friendly message on the other edition, which is exactly the sort of bug nobody notices until a user reports a cryptic stack trace. Returns @{ Status = <int, 0 when the request never reached the server>; RateLimitReset = <DateTimeOffset or $null> }. #> function Get-HttpErrorInfo { param([Parameter(Mandatory)] $ErrorRecord) $info = @{ Status = 0; RateLimitReset = $null } $response = $ErrorRecord.Exception.PSObject.Properties['Response'] | ForEach-Object { $_.Value } if (-not $response) { return $info } try { $info.Status = [int] $response.StatusCode } catch { Write-Verbose 'Response carried no readable status code.' } $resetRaw = $null try { if ($response.Headers -is [System.Net.WebHeaderCollection]) { $resetRaw = $response.Headers['X-RateLimit-Reset'] # PS 5.1 } else { $values = $null # PS 6+ if ($response.Headers.TryGetValues('X-RateLimit-Reset', [ref] $values)) { $resetRaw = @($values)[0] } } } catch { Write-Verbose 'Rate-limit headers were not readable on this response type.' } if ($resetRaw) { try { $info.RateLimitReset = [DateTimeOffset]::FromUnixTimeSeconds([long] $resetRaw).ToLocalTime() } catch { Write-Verbose "Unparsable X-RateLimit-Reset value: $resetRaw" } } return $info } <# Fetches the latest release, translating the GitHub failure modes people actually hit (rate limiting, no releases, no network) into readable messages. #> function Get-LatestRelease { param([Parameter(Mandatory)][string] $Repository) $uri = "$($Script:ApiRoot)/repos/$Repository/releases/latest" Write-Verbose "GET $uri" try { return Invoke-RestMethod -Uri $uri -Headers @{ 'User-Agent' = $Script:UserAgent 'Accept' = 'application/vnd.github+json' } -TimeoutSec 30 } catch { $info = Get-HttpErrorInfo -ErrorRecord $_ Write-Verbose "Request failed with HTTP $($info.Status): $($_.Exception.GetType().FullName)" switch ($info.Status) { 403 { $hint = 'GitHub rejected the request (HTTP 403).' if ($info.RateLimitReset) { $minutes = [math]::Max(1, [int]($info.RateLimitReset - [DateTimeOffset]::Now).TotalMinutes) $hint = "GitHub API rate limit reached (60 requests/hour per IP). Try again in about $minutes minute(s), around $($info.RateLimitReset.ToString('HH:mm'))." } throw [System.Exception]::new($hint, $_.Exception) } 429 { throw [System.Exception]::new('GitHub is throttling this client (HTTP 429). Wait a few minutes and retry.', $_.Exception) } 404 { throw [System.Exception]::new("Repository '$Repository' has no published release, or does not exist.", $_.Exception) } 0 { throw [System.Exception]::new("Could not reach $($Script:ApiRoot). Check your internet connection or proxy settings.", $_.Exception) } default { throw [System.Exception]::new("GitHub returned HTTP $($info.Status) for $uri.", $_.Exception) } } } } <# Picks the .exe asset matching this machine's CPU architecture. Releases that ship both x64 and arm64 builds would otherwise be installed at random. #> function Select-ReleaseAsset { param([Parameter(Mandatory)] $Release) $exeAssets = @($Release.assets | Where-Object { $_.name -like '*.exe' }) if ($exeAssets.Count -eq 0) { throw "No .exe asset found in release $($Release.tag_name)." } if ($exeAssets.Count -eq 1) { return $exeAssets[0] } $arch = $env:PROCESSOR_ARCHITECTURE $patterns = switch ($arch) { 'ARM64' { @('arm64', 'aarch64') } 'AMD64' { @('x64', 'amd64', 'win64') } 'x86' { @('x86', 'win32', 'ia32') } default { @() } } foreach ($pattern in $patterns) { $match = $exeAssets | Where-Object { $_.name -match [regex]::Escape($pattern) } | Select-Object -First 1 if ($match) { return $match } } # No architecture hint in any name: prefer one that does not advertise a foreign # architecture, otherwise fall back to the first asset. $foreign = if ($arch -eq 'ARM64') { 'x86|x64|amd64|win32|win64|ia32' } else { 'arm64|aarch64' } $neutral = $exeAssets | Where-Object { $_.name -notmatch $foreign } | Select-Object -First 1 if ($neutral) { return $neutral } Write-Warn "No asset clearly matches architecture $arch - using $($exeAssets[0].name)." return $exeAssets[0] } function Get-InstalledVersion { param([Parameter(Mandatory)][string] $Path) if (-not (Test-Path -LiteralPath $Path)) { return $null } $raw = (Get-Item -LiteralPath $Path).VersionInfo.ProductVersion if ([string]::IsNullOrWhiteSpace($raw)) { return $null } $parsed = $null if ([Version]::TryParse($raw.Trim(), [ref] $parsed)) { return $parsed } return $null } <# Executables usually carry four version fields (1.20.0.0) while release tags carry three (v1.20.0); normalise both before comparing. #> function ConvertTo-ComparableVersion { param([Parameter(Mandatory)][Version] $Version) [Version]::new($Version.Major, [math]::Max(0, $Version.Minor), [math]::Max(0, $Version.Build)) } #endregion #region -------------------------------------------------------------- process <# Stops only the instances started from $ExePath. Matching on the process name alone would also kill a second, unrelated installation of the same application living in another folder - which the user never asked us to touch. A process whose Path cannot be read (another user, insufficient rights) is left alone rather than killed on a guess. #> function Stop-RunningInstance { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $ExePath ) $candidates = @(Get-Process -Name $Name -ErrorAction SilentlyContinue) if ($candidates.Count -eq 0) { Write-Detail 'No running instance.' return } $running = @() $unknown = 0 foreach ($process in $candidates) { $path = $null try { $path = $process.Path } catch { Write-Verbose "PID $($process.Id): path not readable." } if (-not $path) { $unknown++; continue } if ([string]::Equals($path, $ExePath, [StringComparison]::OrdinalIgnoreCase)) { $running += $process } else { Write-Verbose "Ignoring PID $($process.Id) from a different location: $path" } } if ($unknown -gt 0) { Write-Warn "$unknown process(es) named $Name could not be inspected and were left running." } if ($running.Count -eq 0) { Write-Detail "No running instance started from $ExePath." return } if (-not $PSCmdlet.ShouldProcess("$($running.Count) instance(s) of $ExePath", 'Stop process')) { return } $running | Stop-Process -Force -ErrorAction SilentlyContinue $running | Wait-Process -Timeout 15 -ErrorAction SilentlyContinue Write-Ok "Closed $($running.Count) instance(s)." } function Start-Instance { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string] $Path, [object[]] $Shortcuts = @() ) Write-Step 'Starting the main instance...' if ($PSCmdlet.ShouldProcess($Path, 'Start process')) { Start-Process -FilePath $Path } if (@($Shortcuts).Count -eq 0) { Write-Detail 'No additional-instance shortcut found - started the main instance only.' return } Start-Sleep -Seconds 2 foreach ($shortcut in $Shortcuts) { Write-Step "Starting additional instance: $($shortcut.Name)..." if ($PSCmdlet.ShouldProcess($shortcut.FullName, 'Start process')) { Start-Process -FilePath $shortcut.FullName } } } #endregion #region ------------------------------------------------------- download & verify <# Streams the download so the single-line progress indicator can be drawn as bytes arrive. Returns the number of bytes written. #> function Save-Asset { param( [Parameter(Mandatory)][string] $Uri, [Parameter(Mandatory)][string] $Destination, [long] $ExpectedSize = 0 ) $request = [System.Net.HttpWebRequest]::Create($Uri) $request.UserAgent = $Script:UserAgent $request.Timeout = 60000 $request.ReadWriteTimeout = 120000 $request.AllowAutoRedirect = $true if ([System.Net.WebRequest]::DefaultWebProxy) { $request.Proxy = [System.Net.WebRequest]::DefaultWebProxy $request.Proxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials } $response = $null; $stream = $null; $output = $null try { $response = $request.GetResponse() $total = if ($response.ContentLength -gt 0) { $response.ContentLength } else { $ExpectedSize } $stream = $response.GetResponseStream() $output = [System.IO.File]::Create($Destination) $buffer = New-Object byte[] 131072 $downloaded = 0L $lastDrawn = -1 while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $output.Write($buffer, 0, $read) $downloaded += $read # Redraw once per percentage point to avoid flicker. $pct = if ($total -gt 0) { [int](100 * $downloaded / $total) } else { -1 } if ($pct -ne $lastDrawn) { Write-ProgressBar -Current $downloaded -Total $total $lastDrawn = $pct } } Write-ProgressBar -Current $downloaded -Total $total -Final if ($Script:CanRedraw) { Write-Host '' } if ($total -gt 0 -and $downloaded -ne $total) { throw "Incomplete download: got $downloaded of $total bytes." } return $downloaded } catch [System.Net.WebException] { throw [System.Exception]::new("Download failed: $($_.Exception.Message)", $_.Exception) } finally { if ($output) { $output.Dispose() } if ($stream) { $stream.Dispose() } if ($response) { $response.Dispose() } } } <# Integrity check on the downloaded file, in two independent layers: - SHA-256 against the digest GitHub publishes for the asset; - Authenticode signature. Policy: a matching SHA-256 proves the bytes are exactly the ones GitHub serves for this release, so an *unsigned* file is only a warning - many open-source projects ship unsigned builds, and blocking there would break unattended runs for no gain. A file that is signed but whose signature does not validate (tampered, revoked, untrusted chain) is a different matter and always needs a human answer, as does an unsigned file when GitHub published no digest to check against. Returns $true when it is safe to proceed. #> function Test-DownloadedFile { param( [Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)] $Asset ) $digest = $Asset.PSObject.Properties['digest'] | ForEach-Object { $_.Value } $hashVerified = $false if ($digest -and $digest -match '^sha256:([0-9a-fA-F]{64})$') { $expected = $Matches[1].ToLowerInvariant() $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected) { Write-Fail 'SHA-256 mismatch: the downloaded file does not match the digest published by GitHub.' Write-Detail "expected $expected" Write-Detail "actual $actual" return $false } Write-Ok 'SHA-256 digest matches the one published by GitHub.' $hashVerified = $true } else { Write-Warn 'GitHub published no SHA-256 digest for this asset - hash check not possible.' } if ($SkipSignatureCheck) { Write-Warn 'Authenticode check skipped (-SkipSignatureCheck).' return $true } $signature = Get-AuthenticodeSignature -LiteralPath $Path switch ($signature.Status) { 'Valid' { Write-Ok "Signed by: $($signature.SignerCertificate.Subject)" return $true } 'NotSigned' { if ($hashVerified) { Write-Warn 'Not digitally signed - accepted because the SHA-256 digest matched.' return $true } Write-Warn 'The downloaded executable is neither signed nor covered by a published digest.' } default { Write-Fail "Authenticode status: $($signature.Status) - $($signature.StatusMessage)" } } return (Read-YesNo -Question 'Install this executable anyway?') } <# Antivirus scanners and lingering handles can keep the old executable locked for a few seconds after the process exits, so retry before giving up. #> function Move-WithRetry { param( [Parameter(Mandatory)][string] $Source, [Parameter(Mandatory)][string] $Destination, [int] $Attempts = 5, [int] $DelaySeconds = 2 ) for ($i = 1; $i -le $Attempts; $i++) { try { Move-Item -LiteralPath $Source -Destination $Destination -Force return } catch { if ($i -eq $Attempts) { throw } Write-Detail "Target file still locked - retrying ($i/$Attempts)..." ([ConsoleColor]::DarkYellow) Start-Sleep -Seconds $DelaySeconds } } } <# Puts the verified download in place as safely as the filesystem allows. The new file is downloaded next to the target, on the same volume, so this final move is a rename rather than a cross-volume copy: it either happens or it does not, and an interrupted run can no longer leave a half-written executable behind. The previous build is kept as <name>.bak until the swap succeeds, and restored if it does not, so a failure never costs the user a working application. #> function Install-Executable { param( [Parameter(Mandatory)][string] $Source, [Parameter(Mandatory)][string] $Destination ) $backup = "$Destination.bak" $restored = $false if (Test-Path -LiteralPath $Destination) { Write-Verbose "Backing up the current build to $backup" Move-WithRetry -Source $Destination -Destination $backup } try { Move-WithRetry -Source $Source -Destination $Destination } catch { if (Test-Path -LiteralPath $backup) { try { Move-Item -LiteralPath $backup -Destination $Destination -Force $restored = $true } catch { Write-Verbose 'Restoring the backup failed as well.' } } if ($restored) { Write-Warn 'Install failed - the previous build was restored.' } else { Write-Fail "Install failed and the previous build is still at $backup - rename it back manually." } throw } if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue -WhatIf:$false } } #endregion #region ----------------------------------------------------------------- main function Invoke-Update { # SupportsShouldProcess here, and on the helpers below, so -WhatIf / -Confirm flow # down through $WhatIfPreference instead of relying on the script's $PSCmdlet # leaking into nested scopes. [CmdletBinding(SupportsShouldProcess)] param() Write-Host '' Write-Rule 'Usage Monitor for Claude - updater' if (-not (Test-Windows)) { Write-Fail 'This script updates a Windows executable and only runs on Windows.' return $Script:ExitPlatform } $Script:CanRedraw = Test-CanRedraw Write-Verbose "PowerShell $($PSVersionTable.PSVersion) on $($env:PROCESSOR_ARCHITECTURE); in-place progress redraw: $($Script:CanRedraw)" Initialize-SecurityProtocol # Resolve paths. if (-not $InstallDir) { if (-not $PSScriptRoot) { Write-Fail 'Cannot determine the script folder - pass -InstallDir explicitly.' return $Script:ExitPlatform } # Installed from the PowerShell Gallery, the script sits in a shared scripts # folder that has nothing to do with the application. Defaulting to it would send # the updater looking for an executable in a system directory, so say so instead. if (Test-GalleryInstall -Path $PSScriptRoot) { Write-Fail 'This script was installed from the PowerShell Gallery, so its own folder is not the application folder.' Write-Detail 'Pass -InstallDir with the folder holding the executable, for example:' Write-Detail " Update-UsageMonitor.ps1 -InstallDir 'C:\Tools\UsageMonitor'" ([ConsoleColor]::Gray) return $Script:ExitPlatform } $InstallDir = $PSScriptRoot } $InstallDir = (Resolve-Path -LiteralPath $InstallDir -ErrorAction Stop).Path $exePath = Join-Path $InstallDir $ExeName $processName = [System.IO.Path]::GetFileNameWithoutExtension($ExeName) Write-Step "Checking the latest release on GitHub ($Repo)..." try { $release = Get-LatestRelease -Repository $Repo $asset = Select-ReleaseAsset -Release $release } catch { Write-Fail $_.Exception.Message return $Script:ExitNetwork } $tagVersion = $null if (-not [Version]::TryParse($release.tag_name.TrimStart('v', 'V'), [ref] $tagVersion)) { Write-Fail "Release tag '$($release.tag_name)' is not a version number this script can compare." return $Script:ExitError } $installedVersion = Get-InstalledVersion -Path $exePath if ($installedVersion) { Write-Detail "Installed: $($installedVersion.ToString(3)) Latest release: $($tagVersion.ToString(3))" if ((ConvertTo-ComparableVersion $installedVersion) -eq (ConvertTo-ComparableVersion $tagVersion)) { if ($Force) { Write-Warn 'Same version, but -Force was given: reinstalling.' } else { Write-Ok 'Already on the latest version.' if (-not (Read-YesNo -Question 'Download and re-apply it anyway?')) { Write-Detail 'Nothing to do.' return $Script:ExitOk } Write-Warn 'Reinstalling the same version on request.' } } } elseif (Test-Path -LiteralPath $exePath) { Write-Detail "$exePath carries no readable version - reinstalling." } else { Write-Detail "No executable at $exePath - performing a fresh install." } if (-not (Test-DirectoryWritable -Path $InstallDir)) { Write-Fail "No write permission on $InstallDir." if (-not (Test-Elevated)) { Write-Detail 'Run this script as Administrator, or install to a folder you own.' } return $Script:ExitReplace } if (-not $SkipChangelog) { Show-Changelog -Release $release } Write-Step "Closing running instances of $processName..." Stop-RunningInstance -Name $processName -ExePath $exePath # Staged next to the target, on the same volume, so the final swap is a rename and # not an interruptible cross-volume copy. See Install-Executable. $tempExe = Join-Path $InstallDir "$ExeName.download-$([guid]::NewGuid().ToString('N')).tmp" Write-Verbose "Staging file: $tempExe" try { Write-Step "Downloading $($asset.name) ($(Format-Size $asset.size))..." if ($PSCmdlet.ShouldProcess($asset.browser_download_url, 'Download asset')) { Save-Asset -Uri $asset.browser_download_url -Destination $tempExe -ExpectedSize $asset.size | Out-Null Write-Step 'Verifying the downloaded file...' if (-not (Test-DownloadedFile -Path $tempExe -Asset $asset)) { Write-Fail 'Verification failed - the installed executable was left untouched.' return $Script:ExitVerification } Write-Step "Replacing $exePath..." try { Install-Executable -Source $tempExe -Destination $exePath } catch { Write-Fail "Could not replace the executable: $($_.Exception.Message)" Write-Detail 'Close any process still using it and run the script again.' return $Script:ExitReplace } } } finally { if (Test-Path -LiteralPath $tempExe) { Remove-Item -LiteralPath $tempExe -Force -ErrorAction SilentlyContinue -WhatIf:$false } } $shortcutFilter = "$ExeName - *.lnk" $extraShortcuts = @(Get-ChildItem -LiteralPath $InstallDir -Filter $shortcutFilter -ErrorAction SilentlyContinue) Start-Instance -Path $exePath -Shortcuts $extraShortcuts Write-Host '' Write-Ok "Update complete: version $($tagVersion.ToString(3))." Write-Host '' return $Script:ExitOk } # Dot-sourcing the script (". .\Update-UsageMonitor.ps1") loads the functions without # running anything, which is how the Pester suite gets at them. if ($MyInvocation.InvocationName -ne '.') { try { exit (Invoke-Update) } catch { Write-Host '' Write-Fail $_.Exception.Message if ($_.ScriptStackTrace) { Write-Detail 'Run with -Verbose for details, or report the trace below.' Write-Detail ($_.ScriptStackTrace -replace "`r?`n", ' | ') } Write-Host '' if (-not $NonInteractive -and [Environment]::UserInteractive -and $Host.Name -eq 'ConsoleHost') { # Keep the window open when launched by double-click. Write-Host 'Press Enter to close...' -ForegroundColor DarkGray -NoNewline [void] (Read-Host) } exit $Script:ExitError } } #endregion |