Files

278 lines
11 KiB
PowerShell

# Update check - shared, UI-free.
#
# Both backends need the same two answers ("is a newer version published?" and
# "what do the published release notes say?"), and neither answer involves a UI
# toolkit. Keeping the network + version comparison here means the backends stay
# thin callers (R10) and no WPF type leaks into shared code (R12); the WPF and
# Avalonia sides only differ in how they show the result.
#
# Both versions live in ONE public repo: 3.x on the default branch, 4.x on the
# v4 branch until the two are renamed at release. So every lookup asks for the
# v4 ref first and falls back to no ref, which means the default branch. That
# ordering is self-healing: on the day v4 is renamed to the default branch the
# v4 ref stops existing, the first call 404s, and the fallback reads exactly the
# content that used to be on v4. Nothing here changes at the swap.
$script:AppUpdateRepo = 'Micke-K/IntuneManagement'
# Tried in order. $null means "no ref", i.e. whatever the default branch is.
$script:AppUpdateRefs = @('v4', $null)
# Invoke-RestMethod splat honouring the configured proxy, if any.
function Get-AppUpdateRestParams {
$params = @{}
$proxyURI = Get-ProxyURI
if ($proxyURI) {
$params.Add('proxy', $proxyURI)
$params.Add('UseBasicParsing', $true)
}
return $params
}
# A version that can carry a pre-release label, which [version] cannot:
# [version]'4.0.0-beta1' throws, and so does the 'v4.0.0-beta1' tag form.
#
# ModuleVersion alone cannot tell beta1 from beta2 - both are 4.0.0, because the
# label lives in PrivateData.PSData.Prerelease - so the label is carried here and
# compared, or a beta tester could never be told a newer beta exists.
function ConvertTo-AppVersion {
param([string]$Text)
if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
$value = $Text.Trim()
if ($value.StartsWith('v', [StringComparison]::OrdinalIgnoreCase)) { $value = $value.Substring(1) }
$prerelease = $null
$dash = $value.IndexOf('-')
if ($dash -ge 0) {
$prerelease = $value.Substring($dash + 1)
$value = $value.Substring(0, $dash)
}
$release = $null
try { $release = [version]$value } catch { return $null }
$result = [PSCustomObject]@{
Release = $release
Prerelease = $prerelease
}
# So the UI can keep calling .ToString() and get something a human reads.
$result | Add-Member -MemberType ScriptMethod -Name ToString -Force -Value {
if ($this.Prerelease) { "$($this.Release)-$($this.Prerelease)" } else { "$($this.Release)" }
}
return $result
}
# -1 / 0 / 1, SemVer-style: a pre-release is OLDER than the same release without
# one (4.0.0-beta1 is less than 4.0.0), and two labels compare numerically where
# they can, so beta10 never sorts below beta2.
function Compare-AppVersion {
param($Left, $Right)
if (-not $Left -and -not $Right) { return 0 }
if (-not $Left) { return -1 }
if (-not $Right) { return 1 }
$byRelease = $Left.Release.CompareTo($Right.Release)
if ($byRelease -ne 0) { return [Math]::Sign($byRelease) }
if (-not $Left.Prerelease -and -not $Right.Prerelease) { return 0 }
if (-not $Left.Prerelease) { return 1 }
if (-not $Right.Prerelease) { return -1 }
$leftParts = $Left.Prerelease.Split('.')
$rightParts = $Right.Prerelease.Split('.')
$count = [Math]::Max($leftParts.Count, $rightParts.Count)
for ($i = 0; $i -lt $count; $i++) {
$a = if ($i -lt $leftParts.Count) { $leftParts[$i] } else { '' }
$b = if ($i -lt $rightParts.Count) { $rightParts[$i] } else { '' }
if ($a -eq $b) { continue }
if (-not $a) { return -1 }
if (-not $b) { return 1 }
# beta2 vs beta10: split the trailing digits off and compare those as numbers.
$matchA = [regex]::Match($a, '^(?<t>.*?)(?<n>[0-9]+)$')
$matchB = [regex]::Match($b, '^(?<t>.*?)(?<n>[0-9]+)$')
if ($matchA.Success -and $matchB.Success -and $matchA.Groups['t'].Value -eq $matchB.Groups['t'].Value) {
return [Math]::Sign([int]$matchA.Groups['n'].Value - [int]$matchB.Groups['n'].Value)
}
return [Math]::Sign([string]::Compare($a, $b, [StringComparison]::OrdinalIgnoreCase))
}
return 0
}
# The module manifest published at one ref, as an AppVersion, or $null.
#
# Parsed by writing it to a temp file and reading it with Import-PowerShellDataFile
# - the manifest is PowerShell data, not JSON, and this avoids hand-rolling a
# parser or invoking the text as code.
function Get-AppRemoteManifestVersion {
param([string]$Ref)
$params = Get-AppUpdateRestParams
$url = "https://api.github.com/repos/$($script:AppUpdateRepo)/contents/IntuneManagement.psd1"
if ($Ref) { $url = $url + '?ref=' + $Ref }
$tempManifest = $null
try {
$content = Invoke-RestMethod $url @params
if (-not $content.content) { return $null }
$text = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($content.content))
$tempManifest = [IO.Path]::Combine([IO.Path]::GetTempPath(), "IM-update-$([Guid]::NewGuid().ToString('N')).psd1")
[IO.File]::WriteAllText($tempManifest, $text, (New-Object System.Text.UTF8Encoding($false)))
$data = Import-PowerShellDataFile -Path $tempManifest -ErrorAction Stop
if (-not $data.ModuleVersion) { return $null }
$full = [string]$data.ModuleVersion
$label = $data.PrivateData.PSData.Prerelease
if ($label) { $full = "$full-$label" }
return (ConvertTo-AppVersion $full)
}
catch {
Write-LogDebug "Update check: manifest lookup at ref '$Ref' failed: $($_.Exception.Message)"
return $null
}
finally {
if ($tempManifest -and [IO.File]::Exists($tempManifest)) {
try { [IO.File]::Delete($tempManifest) } catch { }
}
}
}
# Newest published release sharing $Major, as an AppVersion, or $null.
#
# The releases LIST, not releases/latest: that endpoint hides pre-releases by
# design, so a beta could never be told about a newer beta. Releases are not tied
# to a branch either, so this path is unaffected by the rename. tag_name, not
# name - one published release on this repo has an empty name field.
function Get-AppRemoteReleaseVersion {
param([int]$Major = 0)
try {
$params = Get-AppUpdateRestParams
$releases = Invoke-RestMethod "https://api.github.com/repos/$($script:AppUpdateRepo)/releases" @params
$best = $null
foreach ($release in @($releases)) {
if ($release.draft) { continue }
$candidate = ConvertTo-AppVersion $release.tag_name
if (-not $candidate) { continue }
if ($Major -gt 0 -and $candidate.Release.Major -ne $Major) { continue }
if (-not $best -or (Compare-AppVersion $candidate $best) -gt 0) { $best = $candidate }
}
return $best
}
catch {
Write-LogDebug "Update check: releases lookup failed: $($_.Exception.Message)"
return $null
}
}
# Version published on GitHub, or $null when it cannot be determined.
function Get-AppRemoteVersion {
param([int]$Major = 0)
foreach ($ref in $script:AppUpdateRefs) {
$fromManifest = Get-AppRemoteManifestVersion -Ref $ref
if ($fromManifest -and ($Major -le 0 -or $fromManifest.Release.Major -eq $Major)) { return $fromManifest }
}
# No manifest at any ref matched. The 3.x line has no IntuneManagement.psd1
# at all - its manifest is named differently - so ask the releases instead.
return (Get-AppRemoteReleaseVersion -Major $Major)
}
# Version of the manifest sitting next to this module, or $null. Carries the
# pre-release label so one beta can be told from the next.
function Get-AppLocalVersion {
try {
$manifest = Join-Path $script:AppRootFolder 'IntuneManagement.psd1'
if (-not [IO.File]::Exists($manifest)) { return $null }
$data = Import-PowerShellDataFile -Path $manifest -ErrorAction Stop
if (-not $data.ModuleVersion) { return $null }
$full = [string]$data.ModuleVersion
$label = $data.PrivateData.PSData.Prerelease
if ($label) { $full = "$full-$label" }
return (ConvertTo-AppVersion $full)
}
catch {
Write-LogDebug "Update check: local manifest read failed: $($_.Exception.Message)"
}
return $null
}
# Both versions plus the verdict. IsOutdated is only ever $true when both
# versions were resolved, so a failed network call reads as "nothing to report"
# rather than "up to date" or a spurious upgrade prompt.
#
# Only a newer version of the SAME major is offered. Crossing a major is a
# migration with breaking changes, announced deliberately - not something to push
# at an installation through a startup dialog, and not something a branch rename
# should trigger for every 3.x user at once. Drop the -Major argument below to go
# back to "newest wins".
function Get-AppUpdateInfo {
[CmdletBinding()]
param()
$local = Get-AppLocalVersion
$major = if ($local) { $local.Release.Major } else { 0 }
$remote = Get-AppRemoteVersion -Major $major
if (-not $local) { Write-Log 'Failed to get version info from local file' 2 }
if (-not $remote) { Write-Log 'Failed to get version info in GitHub' 2 }
$outdated = ($local -and $remote -and (Compare-AppVersion $local $remote) -lt 0)
if ($outdated) {
Write-Log 'Local version and GitHub version does not match' 2
Write-Log "Local version: $($local.ToString())"
Write-Log "GitHub version: $($remote.ToString())"
}
elseif ($local -and $remote) {
Write-Log "Running latest version: $($local.ToString())"
}
return [PSCustomObject]@{
LocalVersion = $local
RemoteVersion = $remote
IsOutdated = [bool]$outdated
Resolved = [bool]($local -and $remote)
}
}
# Published release notes, or $null when unreachable.
#
# Same ref order as the version lookup, and for the same reason: a 4.x
# installation must not be shown the 3.x notes, and after the rename the
# fallback lands on what used to be the v4 branch with no change here.
#
# Returns Text plus Sha: GitHub's blob sha lets a caller tell "the published notes
# differ from my local copy" without diffing the text, which is how the WPF
# Updates dialog decides whether to flag an update.
function Get-AppRemoteReleaseNotes {
[CmdletBinding()]
param()
$params = Get-AppUpdateRestParams
foreach ($ref in $script:AppUpdateRefs) {
$url = "https://api.github.com/repos/$($script:AppUpdateRepo)/contents/ReleaseNotes.md"
if ($ref) { $url = $url + '?ref=' + $ref }
try {
$content = Invoke-RestMethod $url @params
if ($content.content) {
return [PSCustomObject]@{
Text = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($content.content))
Sha = $content.sha
}
}
}
catch {
Write-LogDebug "Update check: release notes lookup at ref '$ref' failed: $($_.Exception.Message)"
}
}
return $null
}