Update-FarManager.ps1

<#PSScriptInfo
.DESCRIPTION Updates Far Manager and standard plugins.
.VERSION 1.0.0
.AUTHOR Roman Kuzmin
.COPYRIGHT (c) Roman Kuzmin
.TAGS Download Update FarManager
.GUID 184e65a5-08bc-4cfa-b1b4-fe659e62ed66
.PROJECTURI https://github.com/nightroman/FarNet
.LICENSEURI https://github.com/nightroman/FarNet/blob/main/LICENSE
#>


<#
.Synopsis
    Updates Far Manager and standard plugins.
    Author: Roman Kuzmin
 
.Description
    The script updates Far Manager and standard plugins using packages from
    https://github.com/FarGroup/FarManager/releases
    .msi (default) or .7z (requires 7z in the path)
 
    If Far Manager is running the script prompts to exit its running instances.
    Thus, do not run in Far Manager console. But you may start from there using
    "start" or [ShiftEnter] in the command line. In this case parameter FarHome
    may be omitted, $env:FARHOME is used.
 
    $HOME is used for downloaded archives. Old versions are not deleted by
    default, use MaxVersions in order to change.
 
    The script gets the latest web asset name. If the file already exists the
    script stops. Otherwise the file is downloaded and extracted to FarHome.
 
    Then the script removes plugins that did not exist and some files that did
    not exist. See $UnusedFiles below, they include .hlf, .lng, .map, etc.
 
    The script also prints existing extra files not found in the package,
    either added by you or retired by the Far Manager team. In the latter
    case you may want to delete extras.
 
.Notes
    - For .7z packages 7z should be available in the system path.
    - "%TEMP%\FarManager.extracted" is used as temp directory.
 
.Parameter FarHome
        Far Manager directory.
        Default: $env:FARHOME
 
.Parameter Platform
        Platform: "x64" or "x86" / "Win32".
        Default: Value from "Far.exe".
 
.Parameter Archive
        Tells to use this file instead of downloading.
 
.Parameter PackageType
        Package file type: "msi" or "7z".
        Default: "msi" for downloads, else Archive file extension.
 
.Parameter MaxVersions
        Tells how many latest archive versions per the specified Platform
        and PackageType to keep in $HOME. Default: [int]::MaxValue, keep
        all versions.
 
        0 tells to remove all downloaded archives, not recommended, you lose
        the ability of auto-updates, i.e. downloading only newer versions.
#>


[CmdletBinding()]
param(
    [string]$FarHome = $env:FARHOME
    ,
    [ValidateSet('x64', 'x86', 'Win32')]
    [string]$Platform
    ,
    [string]$Archive
    ,
    [ValidateSet('msi', '7z')]
    [string]$PackageType
    ,
    [ValidateRange(0, [int]::MaxValue)]
    [int]$MaxVersions = [int]::MaxValue
)

Set-StrictMode -Version 3
$ErrorActionPreference = 1; trap {$PSCmdlet.ThrowTerminatingError($_)}

### Files to remove after updates if they did not exist.
$UnusedFiles = '*.hlf', '*.lng', '*.cmd', '*.map', 'changelog', 'File_id.diz'

function archive_pattern([string]$Platform, [string]$PackageType) {
    "^Far\.$Platform\.(\d+\.\d+\.\d+\.\d+)\.\w+\.$PackageType$"
}

function get_archives([string]$Platform, [string]$PackageType) {
    $pattern = archive_pattern $Platform $PackageType
    foreach($_ in Get-ChildItem $HOME -File) {
        if ($_.Name -match $pattern) {
            [PSCustomObject]@{
                File = $_
                Version = [version]$Matches[1]
            }
        }
    }
}

function get_old_versions([string]$Platform, [string]$PackageType, [int]$MaxVersions) {
    $files = @(get_archives $Platform $PackageType)
    $nRemove = $files.Count - $MaxVersions
    if ($nRemove -ge 1) {
        $files | Sort-Object Version | Select-Object -First $nRemove
    }
}

if ($MyInvocation.InvocationName -eq '.') {
    return
}

### FarHome
if ($FarHome) {
    $FarHome = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($FarHome)
}
if (![System.IO.Directory]::Exists($FarHome)) {
    throw "Parameter FarHome: directory not found: '$FarHome'."
}

### Platform
if (!$Platform) {
    if (!($exe = Get-Item -LiteralPath "$FarHome\Far.exe" -ErrorAction 0) -or ($exe.VersionInfo.FileVersion -notmatch '\b(x86|x64)\b')) {
        throw "Cannot get platform info from Far.exe.`nSpecify parameter Platform."
    }
    $Platform = $Matches[1]
}

### PackageType
if (!$PackageType) {
    $PackageType = if ($Archive) {[System.IO.Path]::GetExtension($Archive).TrimStart('.')} else {'msi'}
}

### download
if ($Archive) {
    $Archive = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Archive)
    if (![System.IO.File]::Exists($Archive)) {
        throw "Parameter Archive: file not found: '$Archive'."
    }
}
else {
    Write-Host -ForegroundColor Cyan "Getting latest from 'https://github.com/FarGroup/FarManager/releases'..."
    $url = 'https://api.github.com/repos/FarGroup/FarManager/releases/latest'
    $ProgressPreference = 0

    # fetch asset meta
    $res = Invoke-RestMethod -Uri $url
    $Platform = if ($Platform -eq 'x64') {'x64'} else {'x86'}
    $pattern = archive_pattern $Platform $PackageType
    $asset = @($res.assets.Where({ $_.name -match $pattern }))
    if ($asset.Count -ne 1) {
        throw "Cannot find expected download assets."
    }

    # check existing file
    $fileName = $Matches[0]
    $Archive = "$HOME\$fileName"
    if ([System.IO.File]::Exists($Archive)) {
        Write-Host -ForegroundColor Cyan "Archive exists: '$Archive'.`nUse it as the parameter Archive to extract."
        return
    }

    # download
    Write-Host -ForegroundColor Cyan "Downloading '$Archive'..."
    $url = $asset.browser_download_url
    Invoke-WebRequest -Uri $url -OutFile $Archive
}

### exit running
Write-Host -ForegroundColor Cyan "Waiting for Far Manager exit..."
Wait-Process Far -ErrorAction 0

### extract files
Write-Host -ForegroundColor Cyan "Extracting from '$Archive'..."
$plugins1 = [System.IO.Directory]::GetDirectories("$FarHome\Plugins")
$files1 = Get-ChildItem $FarHome -Force -Recurse -File -Name -Include $UnusedFiles

# extract
$extractDir = "$env:TEMP\FarManager.extracted"
if (Test-Path -LiteralPath $extractDir) {
     Remove-Item -LiteralPath $extractDir -Force -Recurse
}
if ($PackageType -eq 'msi') {
    $p = Start-Process msiexec ('/a "{0}" /qn TARGETDIR="{1}"' -f $Archive, $extractDir) -Wait -PassThru
    if ($p.ExitCode) {throw "Extracting files exit code: $($p.ExitCode)."}
    $fromDir = "$extractDir\Far Manager"
}
elseif($PackageType -eq '7z') {
    & 7z x $Archive "-o$extractDir" -aoa
    $fromDir = $extractDir
}
else {
    throw "Unknown package type: '$PackageType'."
}

# copy
if (![System.IO.Directory]::Exists($fromDir)) {throw "Extracted directory not found: '$fromDir'."}
robocopy $fromDir $FarHome /S /NDL /NFL
if ($LASTEXITCODE -notin (0..3)) {throw "robocopy exit code: $LASTEXITCODE."}

### remove not used plugins
Write-Host -ForegroundColor Cyan "Removing not used plugins..."
$plugins2 = [System.IO.Directory]::GetDirectories("$FarHome\Plugins")
foreach($plugin in $plugins2) {
    if ($plugins1 -notcontains $plugin) {
        Write-Host "Removing plugin '$plugin'"
        [System.IO.Directory]::Delete($plugin, $true)
    }
}

### remove not used files
Write-Host -ForegroundColor Cyan "Removing not used files..."
$files2 = Get-ChildItem $FarHome -Force -Recurse -File -Name -Include $UnusedFiles
foreach($file in $files2) {
    if ($files1 -notcontains $file) {
        Write-Host "Removing file '$file'"
        [System.IO.File]::Delete("$FarHome\$file")
    }
}

### clean versions
if ($versions = @(get_old_versions -Platform $Platform -PackageType $PackageType -MaxVersions $MaxVersions)) {
    Write-Host -ForegroundColor Cyan "Removing old versions ($($versions.Count))..."
    $versions | Select-Object -ExpandProperty File | Remove-Item
}

### check extra items
Write-Host -ForegroundColor Cyan "Checking extra items..."
$fromNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
Get-ChildItem -LiteralPath $fromDir -Force -Recurse -Name | .{process{ $null = $fromNames.Add($_) }}
$toNames = @(
    Get-ChildItem -LiteralPath $FarHome -Force -Name
    Get-ChildItem -LiteralPath "$FarHome\Plugins" -Force -Name | .{process{ "Plugins\$_" }}
    foreach($name in Get-ChildItem -LiteralPath "$fromDir\Plugins" -Directory -Name) {
        if ([System.IO.Directory]::Exists("$FarHome\Plugins\$name")) {
            Get-ChildItem -LiteralPath "$FarHome\Plugins\$name" -Force -Recurse -Name | .{process{ "Plugins\$name\$_" }}
        }
    }
)
$nExtra = 0
foreach($_ in $toNames) {
    if (!$fromNames.Contains($_) -and $_ -notmatch '\.chw$') {
        Write-Host $_
        ++$nExtra
    }
}

### clean
Remove-Item -LiteralPath $extractDir -Force -Recurse

### summary
Write-Host -ForegroundColor Cyan "$nExtra extra items."
Write-Host -ForegroundColor Green "Update succeeded."