Wsl-Common/Wsl-Common.Helpers.ps1

<#
    Copyright 2023 Antoine Martin

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

         http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.
#>


function Emoji {
    param (
        [string]$code
    )
    $EmojiIcon = [System.Convert]::toInt32($code, 16)
    return [System.Char]::ConvertFromUtf32($EmojiIcon)
}

$script:HourGlass = Emoji "231B"
$script:PartyPopper = Emoji "1F389"
$script:Eyes = Emoji "1F440"

function Progress {
    param (
        [string]$message
    )
    Write-Host "$script:HourGlass " -NoNewline
    Write-Host -ForegroundColor DarkGray $message
}

function Success {
    param (
        [string]$message
    )
    Write-Host "$script:PartyPopper " -NoNewline
    Write-Host -ForegroundColor DarkGreen $message
}

function Information {
    param (
        [string]$message
    )
    Write-Host "$script:Eyes " -NoNewline
    Write-Host -ForegroundColor DarkYellow $message
}

function Get-UserAgent() {
    return "Wsl-Manager/1.0 (+https://mrtn.me/PowerShell-Wsl-Manager/) PowerShell/$($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor) (Windows NT $([System.Environment]::OSVersion.Version.Major).$([System.Environment]::OSVersion.Version.Minor); $(if(${env:ProgramFiles(Arm)}){'ARM64; '}elseif($env:PROCESSOR_ARCHITECTURE -eq 'AMD64'){'Win64; x64; '})$(if($env:PROCESSOR_ARCHITEW6432 -in 'AMD64','ARM64'){'WOW64; '})$PSEdition)"
}

function info($msg) { write-host "INFO $msg" -f DarkGray }

function ftp_file_size($url) {
    $request = [net.FtpWebRequest]::Create($url)
    $request.Method = [net.WebRequestMethods+Ftp]::GetFileSize
    $request.GetResponse().ContentLength
}


Set-Variable -Name OneGB -Value ([math]::pow(2, 30)) -Option ReadOnly -Scope Script
Set-Variable -Name OneMB -Value ([math]::pow(2, 20)) -Option ReadOnly -Scope Script
Set-Variable -Name OneKB -Value ([math]::pow(2, 10)) -Option ReadOnly -Scope Script

function Format-FileSize {
    param([long]$Bytes)

    if ($null -eq $Bytes) { $Bytes = 0 }

    if ($Bytes -gt $OneGB) {
        "{0:n1} GB" -f ($Bytes / $OneGB)
    }
    elseif ($Bytes -gt $OneMB) {
        "{0:n1} MB" -f ($Bytes / $OneMB)
    }
    elseif ($Bytes -gt $OneKB) {
        "{0:n1} KB" -f ($Bytes / $OneKB)
    }
    else {
        "$Bytes B"
    }
}

# paths
function fileName($path) { split-path $path -leaf }
function strip_filename($path) { $path -replace [regex]::escape((fileName $path)) }

# Unlike url_filename which can be tricked by appending a
# URL fragment (e.g. #/dl.7z, useful for coercing a local filename),
# this function extracts the original filename from the URL.
function url_remote_filename($url) {
    $uri = (New-Object URI $url)
    $basename = Split-Path $uri.PathAndQuery -Leaf
    If ($basename -match ".*[?=]+([\w._-]+)") {
        $basename = $matches[1]
    }
    If (($basename -notlike "*.*") -or ($basename -match "^[v.\d]+$")) {
        $basename = Split-Path $uri.AbsolutePath -Leaf
    }
    If (($basename -notlike "*.*") -and ($uri.Fragment -ne "")) {
        $basename = $uri.Fragment.Trim('/', '#')
    }
    return $basename
}

function Start-Download {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    param(
        [string]$url,
        [string]$to,
        [hashtable]$headers = @{}
    )
    $progress = [console]::IsOutputRedirected -eq $false -and
    $host.name -ne 'Windows PowerShell ISE Host'

    try {
        Invoke-Download -url $url -to $to -progress $progress -headers $headers
    }
    catch {
        $e = $_.exception
        if ($e.InnerException) { $e = $e.InnerException }
        throw $e
    }
}


# download with fileSize and progress indicator
function Invoke-Download ($url, $to, $progress, $headers = @{}) {
    $reqUrl = ($url -split '#')[0]
    $webRequest = [Net.WebRequest]::Create($reqUrl)
    if ($webRequest -is [Net.HttpWebRequest]) {
        $webRequest.UserAgent = Get-UserAgent
        $webRequest.Referer = strip_filename $url
        if ($url -match 'api\.github\.com/repos') {
            $webRequest.Accept = 'application/octet-stream'
            $webRequest.Headers['Authorization'] = "token $(Get-GitHubToken)"
        }
        if ($headers.Count -gt 0) {
            foreach ($header in $headers.GetEnumerator()) {
                $webRequest.Headers[$header.Key] = $header.Value
            }
        }
    }

    try {
        $webResponse = $webRequest.GetResponse()
    }
    catch [System.Net.WebException] {
        $exc = $_.Exception
        $handledCodes = @(
            [System.Net.HttpStatusCode]::MovedPermanently, # HTTP 301
            [System.Net.HttpStatusCode]::Found, # HTTP 302
            [System.Net.HttpStatusCode]::SeeOther, # HTTP 303
            [System.Net.HttpStatusCode]::TemporaryRedirect  # HTTP 307
        )

        # Only handle redirection codes
        $redirectRes = $exc.Response
        if ($handledCodes -notcontains $redirectRes.StatusCode) {
            throw $exc
        }

        # Get the new location of the file
        if ((-not $redirectRes.Headers) -or ($redirectRes.Headers -notcontains 'Location')) {
            throw $exc
        }

        $newUrl = $redirectRes.Headers['Location']
        info "Following redirect to $newUrl..."

        # Handle manual file rename
        if ($url -like '*#/*') {
            $null, $postfix = $url -split '#/'
            $newUrl = "$newUrl#/$postfix"
        }

        Invoke-Download -url $newUrl -to $to -progress$progress
        return
    }

    $total = $webResponse.ContentLength
    if ($total -eq -1 -and $webRequest -is [net.FtpWebRequest]) {
        $total = ftp_file_size($url)
    }

    if ($progress -and ($total -gt 0)) {
        [console]::CursorVisible = $false
        function Trace-DownloadProgress ($read) {
            Write-DownloadProgress -read $read -total $total -url $url
        }
    }
    else {
        write-host "Downloading $url ($(Format-FileSize $total))..."
        function Trace-DownloadProgress {
            #no op
        }
    }

    try {
        $s = $webResponse.GetResponseStream()
        $fs = [io.file]::OpenWrite($to)
        $buffer = new-object byte[] 2048
        $totalRead = 0
        $sw = [diagnostics.stopwatch]::StartNew()

        Trace-DownloadProgress $totalRead
        while (($read = $s.read($buffer, 0, $buffer.length)) -gt 0) {
            $fs.write($buffer, 0, $read)
            $totalRead += $read
            if ($sw.ElapsedMilliseconds -gt 100) {
                $sw.restart()
                Trace-DownloadProgress $totalRead
            }
        }
        $sw.stop()
        Trace-DownloadProgress $totalRead
    }
    finally {
        if ($progress) {
            [console]::CursorVisible = $true
            write-host
        }
        if ($fs) {
            $fs.close()
        }
        if ($s) {
            $s.close();
        }
        $webResponse.close()
    }
}

function Format-DownloadProgress ($url, $read, $total, $console) {
    $filename = url_remote_filename $url

    # calculate current percentage done
    $p = [math]::Round($read / $total * 100, 0)

    # pre-generate LHS and RHS of progress string
    # so we know how much space we have
    $left = "$filename ($(Format-FileSize $total))"
    $right = [string]::Format("{0,3}%", $p)

    # calculate remaining width for progress bar
    $minWidth = $console.BufferSize.Width - ($left.Length + $right.Length + 8)

    # calculate how many characters are completed
    $completed = [math]::Abs([math]::Round(($p / 100) * $minWidth, 0) - 1)

    # generate dashes to symbolize completed
    if ($completed -gt 1) {
        $dashes = [string]::Join("", ((1..$completed) | ForEach-Object { "=" }))
    }

    # this is why we calculate $completed - 1 above
    $dashes += switch ($p) {
        100 { "=" }
        default { ">" }
    }

    # the remaining characters are filled with spaces
    $spaces = switch ($dashes.Length) {
        $minWidth { [string]::Empty }
        default {
            [string]::Join("", ((1..($minWidth - $dashes.Length)) | ForEach-Object { " " }))
        }
    }

    "$left [$dashes$spaces] $right"
}

function Write-DownloadProgress ($read, $total, $url) {
    $console = $host.UI.RawUI;
    $left = $console.CursorPosition.X;
    $top = $console.CursorPosition.Y;
    $width = $console.BufferSize.Width;

    if ($read -eq 0) {
        $maxOutputLength = $(Format-DownloadProgress -url $url -read 100 -total $total -console $console).length
        if (($left + $maxOutputLength) -gt $width) {
            # not enough room to print progress on this line
            # print on new line
            write-host
            $left = 0
            $top = $top + 1
            if ($top -gt $console.CursorPosition.Y) { $top = $console.CursorPosition.Y }
        }
    }

    write-host $(Format-DownloadProgress -url $url -read $read -total $total -console $console) -noNewline
    [console]::SetCursorPosition($left, $top)
}