Vaporup.Wsl.psm1


function Get-WslDistroInstallState {
    <#
    .SYNOPSIS
        Lists all WSL distros available online (wsl -l -o) with their installed state.
    .EXAMPLE
        Get-WslDistroInstallState
    .EXAMPLE
        Get-WslDistroInstallState -Missing
    .EXAMPLE
        Get-WslDistroInstallState -Missing | ForEach-Object { wsl --install $_.Name }
    #>

    [CmdletBinding()]
    param(
        # Only return distros that are NOT installed
        [switch]$Missing
    )

    #$env:WSL_UTF8=1

    # Installed distro names (registered names)
    $installed = @(wsl --list --quiet | ForEach-Object { $_.Trim() } | Where-Object { $_ })

    # Available distros: lines of "NAME FRIENDLY NAME"
    $online = wsl --list --online | ForEach-Object {
        if ($_ -match '^\s*(\S+)\s{2,}(.+?)\s*$' -and $Matches[1] -ne 'NAME') {
            [pscustomobject]@{
                Name         = $Matches[1]
                FriendlyName = $Matches[2]
            }
        }
    }

    $result = foreach ($d in $online) {
        [pscustomobject]@{
            Name         = $d.Name
            FriendlyName = $d.FriendlyName
            Installed    = $installed -contains $d.Name   # -contains is case-insensitive
        }
    }

    if ($Missing) {
      $result | Where-Object { -not $_.Installed }
    } else {
      $result
    }
}

function Update-WslDistroPackages {

  <#
  .SYNOPSIS
    Updates all packages in your WSL instances
 
  .DESCRIPTION
    The script loops over all your WSL instances
    and calls the distribution-specific package manager to upgrade it
 
    Environment Variables:
 
      HTTP_PROXY : http://[username:passwort@]host:port
      HTTPS_PROXY : https://[username:passwort@]host:port
 
    If not set, defaults are used
 
  .NOTES
    Author: Sven Wick <sven.wick@gmx.de>
 
    Using Out-Default in this script to also capture WSL output for Start-Transcript
 
    https://stackoverflow.com/questions/13160759/start-transcript-not-capturing-all-output-to-log-file
    https://github.com/PowerShell/PowerShell/issues/10994
  #>


  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
  param(
    [string[]]$Instance,
    [string]$Proxy,
    [string]$CheckUrl    = "https://www.google.com",
    [string]$LogDirName  = "Vaporup.Wsl",
    [string]$LogFileName = "Update-WslDistroPackages.log",
    [switch]$Force
  )

  #
  # Defaults
  #

  # Make sure we use UTF-8 so we can properly parse the output from WSL
  # https://stackoverflow.com/questions/67746179/how-do-i-match-on-wsl-output-in-powershell
  # WSL produces UTF-16LE-encoded output by default
  # instead of respecting the console's active code page
  $SavedEncoding = [Console]::OutputEncoding
  #$env:WSL_UTF8=1

  #
  # Logging
  #

  $log_dir         = "$env:LOCALAPPDATA\$LogDirName"
  $log_name        = "$LogFileName"
  $log_file        = Join-Path $log_dir $log_name
  $log_max_size_mb = 5

  #
  # System Proxy
  #

  $system_proxy = Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings"

  if ($system_proxy.ProxyEnable -eq 1) {
    $proxy = $system_proxy.ProxyServer
  }

  #
  # Proxy via ENV vars
  #

  if ( $env:HTTP_PROXY ) {
    $proxy = $env:HTTP_PROXY
  }

  if ( $env:HTTPS_PROXY ) {
    $proxy = $env:HTTPS_PROXY
  }

  if ( $Proxy ) {
    $proxy = $Proxy
  } else {
    $proxy = "no proxy"
  }

  $proxy_uri = [System.Uri]$proxy

  #$proxy_protocol = $proxy_uri.Scheme
  $proxy_host     = $proxy_uri.Host
  $proxy_port     = $proxy_uri.Port
  $proxy_userInfo = $proxy_uri.UserInfo

  # Split UserInfo into username and password
  $proxy_username, $proxy_password = $proxy_userInfo -split ":", 2

  #
  # Commands
  #

  $proxy_cmd            = "export http_proxy=""$proxy"" ; export https_proxy=""$proxy"""

  $apk_cmd              = "apk upgrade --no-interactive"
  $apk_cmd_proxy        = "$proxy_cmd ; $apk_cmd"

  $zypper_cmd           = "zypper update -y"
  $zypper_cmd_proxy     = "$proxy_cmd ; $zypper_cmd"

  $pacman_cmd           = "pacman -Syu --noconfirm"
  $pacman_cmd_proxy     = "$proxy_cmd ; $pacman_cmd"

  # https://stackoverflow.com/questions/33370297/apt-get-update-non-interactive
  $apt_cmd              = "export DEBIAN_FRONTEND=noninteractive ; apt-get update ; apt-get -y dist-upgrade; apt-get -y autoremove"
  $apt_cmd_proxy        = "$proxy_cmd ; $apt_cmd"

  $dnf_cmd              = "dnf upgrade -y"
  $dnf_cmd_proxy        = "$proxy_cmd ; $dnf_cmd"

  $dnf_or_yum_cmd       = "command -v dnf > /dev/null && dnf upgrade -y ; command -v yum > /dev/null && yum -y update"
  $dnf_or_yum_cmd_proxy = "$proxy_cmd ; $dnf_or_yum_cmd"

  #
  # Functions
  #

  function CheckInternetConnection {

    #
    # Try direct
    #

    Add-Type -AssemblyName "System.Net.Http"
    $handler = New-Object System.Net.Http.HttpClientHandler
    $handler.UseProxy = $false
    $httpClient = New-Object System.Net.Http.HttpClient($handler)
    $response = $httpClient.GetAsync($CheckUrl).Result

    if ($response.IsSuccessStatusCode) {
      return "direct"
    }

    #
    # Try Proxy
    #

    # but only when configured

    if ($proxy_host -and $proxy_port) {
      $connect = Test-NetConnection -ComputerName $proxy_host -Port $proxy_port -WarningAction:SilentlyContinue

      if ($connect.TcpTestSucceeded) {
        return "proxy"
      }
    } else {
        return "failed"
    }

    #
    # No internet connection possible
    #

    return "failed"

  }

  function Write-TerminalLine {

    param(
        [string]$Character = '-',
        [int]$FallbackWidth = 120
    )

    # Get terminal width, fallback if running headlessly
    $width = try {
        [Console]::WindowWidth
    } catch {
        $FallbackWidth
    }

    # Print the line
    $Character * $width
  }

  #
  # Locking
  #

  $mutexName = "Global\Update-WslDistroPackages"
  $mutex     = New-Object System.Threading.Mutex($false, $mutexName)

  if (-not $mutex.WaitOne(0, $false)) {
    Write-Warning "Another update process is already running."
    Return
  }

  #
  # Main
  #

  try {

    Write-Output ""

    # Use a temporary transcript file
    $shortId        = ([guid]::NewGuid().Guid -replace '-','').Substring(0, 10)
    $tempTranscript = Join-Path $env:TEMP ("Update-WslDistroPackages_" + $shortId + ".log")

    # Start the transcript
    Start-Transcript -Path $tempTranscript | Out-Null

    # Ensure log directory exists
    if (-not (Test-Path $log_dir)) {
      New-Item -ItemType Directory -Path $log_dir | Out-Null
    }

    # Start new logfile when it exceeds max size
    if (Test-Path $log_file) {
      $logSizeMB = (Get-Item $log_file).Length / 1MB
      if ($logSizeMB -ge $log_max_size_mb) {
        Remove-Item $log_file -Force
      }
    }

    #
    # Output configuration
    #

    Write-Output "Transcript : $tempTranscript"
    Write-Output "Logfile : $log_file"

    #
    # Return early if there is no WSL
    #

    $wslServiceFound = $false
    $wslServices     = Get-Service "WSLService" -ErrorAction SilentlyContinue

    if ( $wslServices.Count -gt 0 ) {
      $wslServiceFound = $true
    }

    if (-not $wslServiceFound ) {

      Write-Warning "No WSLService found."
      Return

    }

    if (-not (Test-Path "C:\Windows\System32\wsl.exe")) {
      Write-Warning "WSL is not installed."
      Return
    }

    #
    # Get the list of WSL instances
    #

    $wslInstances = @()
    $allInstances = wsl --list --quiet | Sort-Object

    #
    # Return early if WSL throws an error
    #

    if ($LASTEXITCODE -ne 0) {
      Write-Warning "Finding WSL instances failed with exit code $LASTEXITCODE"
      Return
    }

    #
    # Return early if no instances found
    #

    if (-not $allInstances) {
      Write-Warning "Could not find any WSL instances"
      Return
    }

    #
    # Check if there was supplied a subset of instances
    # If not, update all that were found
    #

    if ($Instance) {
      foreach ($name in $Instance) {
        if ($allInstances -contains $name) {
          $wslInstances = $wslInstances + $name
        } else {
            Write-Warning "Instance '$name' not found."
        }
      }
    } else {
      $wslInstances = $allInstances
    }

    Write-Output ""
    Write-Output "The following instances will be updated:"
    Write-Output ""

    $wslInstances | ForEach-Object { " * $_" }

    if (-not ($Force -or $PSCmdlet.ShouldProcess("WSL Instance(s)", "Update"))) {
      Write-Warning "Update cancelled."
      return
    }

    Write-Output ""
    Write-Warning "Setting Console Output to UTF8"
    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8

    #
    # Detect Internet Connection
    #

    Write-Output ""
    Write-Output "Using $CheckUrl for checking direct internet connection with $proxy as fallback"

    Write-Output "Checking Internet Connection..."
    $connection = CheckInternetConnection

    if ($connection -like "failed") {
      Write-Warning "No Internet Connection available"
      Return
    }

    Write-Output ""
    Write-Output "CONNECTION : $connection"

    #
    # Updating
    #

    $counter = 0

    foreach ($wslInstance in $wslInstances) {

      if ($wslInstance -eq "") {
        continue
      }

      $counter    = $counter + 1
      $distroID   = "unknown"
      $pkgManager = "unknown"

      try {
        $distroInfo = wsl -u root -d $wslInstance cat /etc/os-release
        $distroID = ($distroInfo | Select-String -Pattern "^ID=").Line.Split('=')[1].Trim().Trim('"')
      } catch {
        $distroID = "unknown"
      }

      #
      # Check special instances like "docker-desktop" (alpine)
      #

      if ($distroID -eq "unknown") {
        try {
          $distroID = wsl -u root -d $wslInstance sh -c "test -f /etc/alpine-release && echo alpine"

          if (-not $distroID) {
            $distroID = "unknown"
          }
        } catch {
          $distroID = "unknown"
        }

      }

      switch -Wildcard ($distroID) {

        "*unknown*" { $pkgManager = "unknown" }
        "*alpine*"  { $pkgManager = "apk"     }
        "*debian*"  { $pkgManager = "apt"     }
        "*ubuntu*"  { $pkgManager = "apt"     }
        "*kali*"    { $pkgManager = "apt"     }
        "*elxr*"    { $pkgManager = "apt"     }
        "*suse*"    { $pkgManager = "zypper"  }
        "*sles*"    { $pkgManager = "zypper"  }
        "*fedora*"  { $pkgManager = "dnf"     }
        "*alma*"    { $pkgManager = "dnf"     }
        "*rocky*"   { $pkgManager = "dnf"     }
        "*arch*"    { $pkgManager = "pacman"  }

      }

      # Oracle Linux (Old versions still use yum)

      if ($distroID -eq "ol") {
        $pkgManager = "dnf_or_yum"
      }

      # +----------+
      # | UPDATING |
      # +----------+

      Write-Output ""
      Write-TerminalLine
      Write-Output "INSTANCE_NUMBER : ${counter}/$($wslInstances.Count)"
      Write-Output "INSTANCE_NAME : $wslInstance"
      Write-Output "DISTRO_ID : $distroID"
      Write-Output "PACKAGE_MANAGER : $pkgManager"

      #
      # Unknown
      #

      if ($pkgManager -eq "unknown") {
        Write-TerminalLine
        Write-Output ""
        Write-Warning "Unknown how to update. Skipping $wslInstance"

        continue
      }

      switch ($pkgManager) {

        "dnf_or_yum" {

          $update_cmd = $dnf_or_yum_cmd

          if ($connection -like "proxy") {
            $update_cmd = $dnf_or_yum_cmd_proxy
          }
        }

        "dnf" {

          $update_cmd = $dnf_cmd

          if ($connection -like "proxy") {
            $update_cmd = $dnf_cmd_proxy
          }
        }

        "apk" {

          $update_cmd = $apk_cmd

          if ($connection -like "proxy") {
            $update_cmd = $apk_cmd_proxy
          }
        }

        "apt" {

          $update_cmd = $apt_cmd

          if ($connection -like "proxy") {
            $update_cmd = $apt_cmd_proxy
          }
        }

        "pacman" {

          $update_cmd = $pacman_cmd

          if ($connection -like "proxy") {
            $update_cmd = $pacman_cmd_proxy
          }
        }

        "zypper" {

          $update_cmd = $zypper_cmd

          if ($connection -like "proxy") {
            $update_cmd = $zypper_cmd_proxy
          }
        }

      }

      Write-Output "UPDATE_COMMAND : $update_cmd"
      Write-TerminalLine
      Write-Output ""

      wsl -u root -d $wslInstance sh -c $update_cmd | Out-Default

      continue

    }

    Write-Output       ""
    Write-TerminalLine "="
    Write-Output       "FINISHED..."
    Write-Output       ""
    Write-Output       "Logfile: $log_file"
    Write-Output       ""

    Stop-Transcript | Out-Null

    Write-Output ""

    # Append temp transcript to daily log and then remove it
    if (Test-Path $tempTranscript) {
      Get-Content $tempTranscript | Add-Content -Path $log_file -Encoding UTF8
      Remove-Item $tempTranscript -Force
    }

  } finally {

    Write-Warning "Setting Console Output back to Default"
    [Console]::OutputEncoding = $SavedEncoding
    #[Console]::OutputEncoding = [System.Text.Encoding]::Default
    
    try {
      Stop-Transcript | Out-Null
    } catch {
    }

    # Append temp transcript to daily log and then remove it
    if (Test-Path $tempTranscript) {
      Get-Content $tempTranscript | Add-Content -Path $log_file -Encoding UTF8
      Remove-Item $tempTranscript -Force
    }

    $mutex.ReleaseMutex()
    $mutex.Dispose()
  }

}

#
# TAB Completion for $Instance
#

# Make sure we use UTF-8 so we can properly parse the output from WSL
# https://stackoverflow.com/questions/67746179/how-do-i-match-on-wsl-output-in-powershell
# WSL produces UTF-16LE-encoded output by default
# instead of respecting the console's active code page
$env:WSL_UTF8=1

Register-ArgumentCompleter -CommandName Update-WslDistroPackages -ParameterName Instance -ScriptBlock {
  param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

  wsl.exe --list --quiet | ForEach-Object {
    if ($_ -like "$wordToComplete*") {
       Write-Output "$_"
    }
  }
}