Download-WinSCPPortable.ps1

<#PSScriptInfo
.VERSION 1.1.0
.GUID a3f9c812-5e2b-4d7a-b1f6-8c3e0d9a4b7e
.AUTHOR Giovanni Solone
.TAGS powershell winscp portable download tools
.LICENSEURI https://opensource.org/licenses/MIT
.PROJECTURI https://github.com/gioxx/Nebula.Scripts/blob/main/Utility/Download-WinSCPPortable.ps1
.RELEASENOTES
v1.1.0 (2026-05-07): Added ShowProgress and switched extraction to a temporary folder workflow for more reliable file deployment. Preserve both WinSCPnet.dll variants for Windows PowerShell 5.1 and PowerShell 7.
v1.0.0 (2026-03-26): Initial release.
#>


#Requires -Version 7.0

<#
.SYNOPSIS
Downloads the latest WinSCP Portable version (and optionally the .NET assembly / COM library) to a specified folder.

.DESCRIPTION
This script checks the official WinSCP download page, retrieves the latest version number, and downloads
the portable ZIP package directly from winscp.net by extracting the tokenized download URL from the
download page. It extracts the contents into a temporary folder and copies them to the destination
folder. Optionally, the .NET assembly / COM library ZIP can also be downloaded, preserving both
WinSCPnet.dll variants so the portable layout works with Windows PowerShell 5.1 and PowerShell 7.
If the destination folder already contains files from the same version, the download is skipped.

.PARAMETER Destination
The folder where the portable files will be extracted. Must be specified.

.PARAMETER IncludeDotNet
If specified, also downloads the .NET assembly / COM library package and extracts both WinSCPnet.dll variants.

.PARAMETER ShowProgress
If specified, displays additional progress information while downloading and extracting files.

.EXAMPLE
.\Download-WinSCPPortable.ps1 -Destination "C:\Tools\WinSCP"
Downloads the latest WinSCP portable package and extracts it to C:\Tools\WinSCP.

.EXAMPLE
.\Download-WinSCPPortable.ps1 -Destination "C:\Tools\WinSCP" -IncludeDotNet
Downloads the portable package and extracts both WinSCPnet.dll variants to C:\Tools\WinSCP.

.EXAMPLE
.\Download-WinSCPPortable.ps1 -Destination "C:\Tools\WinSCP" -IncludeDotNet -ShowProgress
Downloads the portable package and shows additional progress details while extracting files.

.NOTES
[Reflection.Assembly]::LoadFile("C:\path\file.dll").ImageRuntimeVersion to check .NET version of a DLL file.
#>


param (
    [Parameter(Mandatory = $true)]
    [string] $Destination,
    [switch] $IncludeDotNet,
    [switch] $ShowProgress
)

function Write-ProgressMessage {
    param (
        [Parameter(Mandatory = $true)]
        [string] $Message
    )

    if ($ShowProgress) {
        Write-Host $Message
    }
}

function Resolve-DestinationPath {
    param (
        [Parameter(Mandatory = $true)]
        [string] $Path
    )

    if ([System.IO.Path]::IsPathRooted($Path)) {
        return [System.IO.Path]::GetFullPath($Path)
    }

    $basePath = (Get-Location).ProviderPath
    return [System.IO.Path]::GetFullPath((Join-Path -Path $basePath -ChildPath $Path))
}

function New-DirectoryIfMissing {
    param (
        [Parameter(Mandatory = $true)]
        [string] $Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        New-Item -ItemType Directory -Path $Path -Force | Out-Null
    }
}

function New-TempExtractionFolder {
    param (
        [Parameter(Mandatory = $true)]
        [string] $Prefix
    )

    $tempRoot = [System.IO.Path]::GetTempPath()
    $tempFolder = Join-Path $tempRoot ("{0}_{1}" -f $Prefix, [System.Guid]::NewGuid().ToString("N").Substring(0, 8))
    New-Item -ItemType Directory -Path $tempFolder -Force | Out-Null
    return $tempFolder
}

function Copy-FolderContentsWithRetry {
    param (
        [Parameter(Mandatory = $true)]
        [string] $SourceFolder,

        [Parameter(Mandatory = $true)]
        [string] $DestinationFolder
    )

    $maxRetries = 3
    $attempt = 0

    while ($attempt -lt $maxRetries) {
        try {
            Copy-Item -Path (Join-Path $SourceFolder '*') -Destination $DestinationFolder -Recurse -Force -ErrorAction Stop
            return
        }
        catch {
            $attempt++
            if ($attempt -lt $maxRetries) {
                Start-Sleep -Seconds 2
            }
            else {
                throw
            }
        }
    }
}

function Copy-FileWithRetry {
    param (
        [Parameter(Mandatory = $true)]
        [string] $SourceFile,

        [Parameter(Mandatory = $true)]
        [string] $DestinationFile
    )

    New-DirectoryIfMissing -Path (Split-Path -Path $DestinationFile -Parent)

    $maxRetries = 3
    $attempt = 0

    while ($attempt -lt $maxRetries) {
        try {
            Copy-Item -LiteralPath $SourceFile -Destination $DestinationFile -Force -ErrorAction Stop
            return
        }
        catch {
            $attempt++
            if ($attempt -lt $maxRetries) {
                Write-ProgressMessage " $([System.IO.Path]::GetFileName($DestinationFile)) is locked, retrying in 3 seconds... (attempt $attempt/$maxRetries)"
                Start-Sleep -Seconds 3
            }
            else {
                throw
            }
        }
    }
}

function Get-WinSCPVersion {
    $WinSCP_URL = "https://winscp.net/eng/download.php"
    $response = Invoke-WebRequest -Uri $WinSCP_URL -UseBasicParsing
    $versionRegex = 'WinSCP-(\d+\.\d+\.\d+)-Setup\.exe'
    $versionMatch = [regex]::Match($response.Content, $versionRegex)
    if (-not $versionMatch.Success) {
        Write-Error "Version number not found on WinSCP download page."
        exit 1
    }
    return $versionMatch.Groups[1].Value
}

function Test-AlreadyUpToDate {
    param (
        [string] $FilePath,
        [string] $Version
    )
    if (Test-Path $FilePath) {
        $fileVersion = (Get-Item $FilePath).VersionInfo.FileVersion -replace ',', '.' -replace ' ', ''
        if ($fileVersion -like "$Version*") {
            return $true
        }
    }
    return $false
}

function Get-WinSCPPackage {
    param (
        [string] $Version,
        [string] $FileName,
        [string] $DisplayName
    )
    $tempPath = [System.IO.Path]::GetTempPath()
    $zipFile = Join-Path $tempPath $FileName

    $downloadPageUrl = "https://winscp.net/download/$FileName/download"
    Write-ProgressMessage "Downloading $DisplayName..."

    # Load the download page to extract the tokenized URL
    $response = Invoke-WebRequest -Uri $downloadPageUrl -UseBasicParsing -SessionVariable session -MaximumRedirection 10

    if ($response.Content -notmatch '/download/files/[^\s"'']+') {
        Write-Error "Could not find tokenized download URL in WinSCP download page."
        exit 1
    }
    $tokenizedUrl = "https://winscp.net" + $matches[0]
    Write-ProgressMessage "Token URL: $tokenizedUrl"

    # Download the actual file using the tokenized URL and the session cookie
    Invoke-WebRequest -Uri $tokenizedUrl -OutFile $zipFile -UseBasicParsing -WebSession $session

    # Verify it's a valid ZIP (PK header: 80 75)
    $fileHeader = Get-Content -Path $zipFile -AsByteStream -TotalCount 2
    if ($fileHeader[0] -ne 80 -or $fileHeader[1] -ne 75) {
        Write-Error "The downloaded file does not appear to be a valid ZIP archive."
        Remove-Item -LiteralPath $zipFile -Force
        exit 1
    }

    return $zipFile
}

function Expand-DotNetDll {
    param (
        [string] $ZipPath,
        [string] $Destination
    )
    $tempFolder = New-TempExtractionFolder -Prefix 'WinSCP_Automation'

    function Resolve-SourceFile {
        param (
            [Parameter(Mandatory = $true)]
            [string] $BasePath,

            [Parameter(Mandatory = $true)]
            [string[]] $Candidates
        )

        foreach ($candidate in $Candidates) {
            $candidatePath = Join-Path $BasePath $candidate
            if (Test-Path -LiteralPath $candidatePath) {
                return (Get-Item -LiteralPath $candidatePath).FullName
            }
        }

        return $null
    }

    try {
        Expand-Archive -Path $ZipPath -DestinationPath $tempFolder -Force

        $rootSource = Resolve-SourceFile -BasePath $tempFolder -Candidates @(
            'WinSCPnet.dll'
            'net40\WinSCPnet.dll'
        )

        if (-not $rootSource) {
            $rootSource = Get-ChildItem -Path $tempFolder -Filter 'WinSCPnet.dll' -Recurse -File |
                Where-Object { $_.FullName -notmatch 'netstandard2\.0' } |
                Select-Object -First 1 -ExpandProperty FullName
        }

        if (-not $rootSource) {
            Write-Error "WinSCPnet.dll not found in the ZIP archive."
            exit 1
        }

        Copy-FileWithRetry -SourceFile $rootSource -DestinationFile (Join-Path $Destination 'WinSCPnet.dll')
        Write-ProgressMessage " Extracted: WinSCPnet.dll"

        $netstandardSource = Resolve-SourceFile -BasePath $tempFolder -Candidates @(
            'netstandard2.0\WinSCPnet.dll'
        )

        if (-not $netstandardSource) {
            $netstandardSource = Get-ChildItem -Path $tempFolder -Filter 'WinSCPnet.dll' -Recurse -File |
                Where-Object { $_.FullName -match 'netstandard2\.0' } |
                Select-Object -First 1 -ExpandProperty FullName
        }

        if (-not $netstandardSource) {
            Write-Error "WinSCPnet.dll not found under netstandard2.0 in the ZIP archive."
            exit 1
        }

        Copy-FileWithRetry -SourceFile $netstandardSource -DestinationFile (Join-Path $Destination 'netstandard2.0\WinSCPnet.dll')
        Write-ProgressMessage " Extracted: netstandard2.0\WinSCPnet.dll"
    }
    finally {
        if (Test-Path -LiteralPath $tempFolder) {
            Remove-Item -LiteralPath $tempFolder -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

# --- Main ---

$Destination = Resolve-DestinationPath -Path $Destination

$version = Get-WinSCPVersion
Write-Output "Latest WinSCP version: $version"

# Create destination folder if it doesn't exist
if (-not (Test-Path -LiteralPath $Destination)) {
    New-Item -ItemType Directory -Path $Destination -Force | Out-Null
    Write-ProgressMessage "Created destination folder: $Destination"
}

# Portable package
$exePath = Join-Path $Destination "WinSCP.exe"
if (Test-AlreadyUpToDate -FilePath $exePath -Version $version) {
    Write-Output "WinSCP $version portable is already up to date in: $Destination"
}
    else {
        $zipFile = Get-WinSCPPackage -Version $version -FileName "WinSCP-$version-Portable.zip" -DisplayName "WinSCP $version Portable"
        Write-ProgressMessage "Extracting to: $Destination"
        $tempFolder = New-TempExtractionFolder -Prefix 'WinSCP_Portable'
        try {
            Expand-Archive -Path $zipFile -DestinationPath $tempFolder -Force
            Copy-FolderContentsWithRetry -SourceFolder $tempFolder -DestinationFolder $Destination
        Write-Output "Portable package ready in: $Destination"
    }
    finally {
        if (Test-Path -LiteralPath $tempFolder) {
            Remove-Item -LiteralPath $tempFolder -Recurse -Force -ErrorAction SilentlyContinue
        }
        Remove-Item -LiteralPath $zipFile -Force -ErrorAction SilentlyContinue
    }
}

# .NET assembly / COM library (optional)
if ($IncludeDotNet) {
    $dllPath = Join-Path $Destination "WinSCPnet.dll"
    if (Test-AlreadyUpToDate -FilePath $dllPath -Version $version) {
        Write-Output "WinSCPnet.dll $version is already up to date in: $Destination"
    }
    else {
        $zipFile = Get-WinSCPPackage -Version $version -FileName "WinSCP-$version-Automation.zip" -DisplayName "WinSCP $version .NET assembly / COM library"
        Write-ProgressMessage "Extracting WinSCPnet.dll variants to: $Destination"
        Expand-DotNetDll -ZipPath $zipFile -Destination $Destination
        Remove-Item -LiteralPath $zipFile -Force -ErrorAction SilentlyContinue
        Write-Output ".NET assembly ready in: $Destination"
    }
}