modules/WinGet-Restore.psm1

#Requires -Modules TableUI
Set-StrictMode -Version 3
Import-Module "$PSScriptRoot\WinGet-Utils.psm1"

[string]$PackageDatabase = "$PSScriptRoot\winget.packages.json"
[string]$PackageDatabaseSchema = "$PSScriptRoot\schema\packages.schema.json"
[string]$CheckpointFilePath = "$PSScriptRoot\winget.{HOSTNAME}.checkpoint"
[string]$FakePackageExpression = "<.*>"
[string]$DefaultSource = 'winget'

<#
.DESCRIPTION
    Restore a collection of packages based on the provided list of tags.
 
.EXAMPLE
    PS> Restore-WinGetSoftware -All -UseUI
 
.EXAMPLE
    PS> Restore-WinGetSoftware -Tag Dev,Essential
#>

function Restore-WinGetSoftware
{
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Filter', ConfirmImpact = 'High')]
    param (
        # The specified tags to filter and determine which software to install.
        # A matching package is one that contains all the specified tags.
        # See $MatchAny to change the filtering behavior for this parameter.
        [Parameter(Mandatory, ParameterSetName = 'Filter')]
        [string[]]$Tag,

        # When set, the specified list of $Tag will no longer require a package
        # to contain all the specified tags for it to be considered a match,
        # as long as one of the tags is associated with the package it will be
        # considered a match. The default behavior is to "Match All" tags.
        # This switch does not affect $ExcludeTag behavior.
        [Parameter(ParameterSetName = 'Filter')]
        [switch]$MatchAny,

        # An optional list of tags which will filter a package from the
        # install list if it contains ANY of tags specified in this list.
        [Parameter()]
        [string[]]$ExcludeTag = @(),

        # When set, all packages in "winget.packages.json" will be selected.
        [Parameter(Mandatory, ParameterSetName = 'NoFilter')]
        [switch]$All,

        # When set, packages listed in the "checkpoint" file (generated via
        # Checkpoint-WinGetSoftware) will be filtered from the list. Thus
        # supplying a list of packages that are not installed on the system.
        [Parameter()]
        [switch]$NotInstalled,

        # When set, a CLI based UI will be presented to allow for more refined
        # selection of packages to install.
        [Parameter()]
        [switch]$UseUI,

        # When set, ignore the 'Version' of all packages to be installed and use
        # the latest version available. However, packages with 'VersionLock' set
        # will be respected.
        [Parameter()]
        [switch]$UseLatest,

        # When set, indicates that interactive installation should be used,
        # requires user to navigate install wizards.
        [Parameter()]
        [switch]$Interactive,

        # Acts as an inverse of -Confirm. Provided for convenience.
        [Parameter()]
        [switch]$Force,

        # Launches and runs the invoked command in an administrator instance of
        # PowerShell.
        [Parameter()]
        [switch]$Administrator,

        # Bypasses schema validation for the "winget.packages.json". This is
        # for testing purposes and should not be used in normal usage.
        [Parameter()]
        [switch]$SkipValidation
    )

    function Write-ProgressHelper
    {
        param (
            [PSObject[]]$Packages,
            [int]$PackageIndex
        )

        $i = $PackageIndex + 1
        $bar = ('─' * 64) # Match Width of TableUI
        Write-Output "`r$bar"
        Write-Output "[ $i / $($Packages.Count) ] Installing '$($Packages[$PackageIndex].PackageIdentifier)'"
        Write-Output "$bar`n"
    }

    if ($Administrator -and -not(Test-Administrator)) {
        $boundParamsString = $PSBoundParameters.Keys | ForEach-Object {
            if ($PSBoundParameters[$_] -is [switch]) {
                if ($PSBoundParameters[$_]) {
                    "-$($_)"
                }
            } else {
                "-$($_) $($PSBoundParameters[$_])"
            }
        }
        $cmdArgs = "-NoLogo -NoExit -Command Restore-WinGetSoftware $($boundParamsString -join ' ')"
        Start-Process -Verb RunAs -FilePath "pwsh" -ArgumentList $cmdArgs
        return
    }

    if (-not(Test-Administrator) -and -not($Force)) {
        $label = 'Microsoft Article'
        $url = 'https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links'
        Write-Warning ("Some programs will not install correctly if WinGet is used without administrator rights.`n" +
                    "`t This is particularly true for zip-based installs which involve the creation of symbolic`n" +
                    "`t links to export the utility the WinGet 'Links' path. Administrators may grant users`n" +
                    "`t privileges to create symbolic links via local policies.`n" +
                    "`t For more information see: " + (New-HyperLinkText -Label $label -Url $url))
        Write-Output 'Press ENTER to continue ...'
        Wait-ConsoleKeyEnter
    }

    if (-not(Test-Path variable:Confirm)) {
        $Confirm = $false
    }

    if ($Force -and -not $Confirm) {
        $ConfirmPreference = 'None'
    }

    Initialize-WinGetRestore | Out-Null
    if (-not(Test-Path $PackageDatabase)) {
        Write-Error ("`"$PackageDatabase`" does not exist. Please create this file and populate it with tagged winget package identifiers. " +
            "Then use Initialize-WinGetRestore to setup a symlink to this file.")
        return
    }

    $MatchAnyScriptBlock = {
        param([string[]]$p1, [string[]]$p2)

        $tags = $p1
        $packageTags = $p2
        $matchedAny = $false
        $tags | ForEach-Object {
            if ($packageTags -contains $_) {
                $matchedAny = $true
                return
            }
        }

        $matchedAny
    }

    $MatchAllScriptBlock = {
        param([string[]]$p1, [string[]]$p2)

        $tags = $p1
        $packageTags = $p2
        $matchedAll = $true
        $tags | ForEach-Object {
            if ($packageTags -notcontains $_) {
                $matchedAll = $false
                return
            }
        }

        $matchedAll
    }

    $MatchNoneScriptBlock = {
        param([string[]]$p1, [string[]]$p2)

        $tags = $p1
        $packageTags = $p2
        $matchedNone = $true
        $tags | ForEach-Object {
            if ($packageTags -contains $_) {
                $matchedNone = $false
                return
            }
        }

        $matchedNone
    }

    if ($MatchAny) {
        $isMatch = $MatchAnyScriptBlock
    } else {
        $isMatch = $MatchAllScriptBlock
    }

    if (-not($SkipValidation) -and -not(Test-Json -Json (Get-Content $PackageDatabase | Out-String) -SchemaFile $PackageDatabaseSchema)) {
        Write-Error "Schema validation failed for: '$PackageDatabase'. Please fix and try again. The file can be validated against '$PackageDatabaseSchema'."
        return
    }

    $installPackages = @(Get-Content $PackageDatabase | ConvertFrom-Json)

    $checkpointFile = $CheckpointFilePath.Replace('{HOSTNAME}', $(hostname).ToLower())
    if ($NotInstalled) {
        if (Test-Path $checkpointFile) {
            # Check across all sources for packages, not just winget.
            $checkpointPackageIds = (Get-Content $checkpointFile | ConvertFrom-Json).Sources.Packages.PackageIdentifier

            $installPackages = @($installPackages | Where-Object {
                $checkpointPackageIds -notcontains $_.PackageIdentifier
            })
        } else {
            Write-Error "No checkpoint file found. 'Checkpoint-WinGetSoftware' should be run before using -NotInstalled."
            return
        }
    }

    if (-not($All)) {
        $installPackages  = @($installPackages | Where-Object {
            &$isMatch -p1 $Tag -p2 $_.Tags
        })
    }

    if ($ExcludeTag.Count -gt 0) {
        $installPackages  = @($installPackages | Where-Object {
            &$MatchNoneScriptBlock -p1 $ExcludeTag -p2 $_.Tags
        })
    }

    if ($installPackages.Count -eq 0) {
        Write-Output "No packages to install."
        return
    }

    $fakePackages = $installPackages | Where-Object { $_.PackageIdentifier -match $FakePackageExpression } | Sort-Object -Property PackageIdentifier
    $installPackages = $installPackages | Where-Object { $_.PackageIdentifier -notmatch $FakePackageExpression } | Sort-Object -Property PackageIdentifier

    if ($null -ne $fakePackages) {
        $installPackages = $installPackages + $fakePackages
    }

    if ($UseUI) {
        $selections = [bool[]]@()

        $showPackageDetailsScriptBlock = {
            param($currentSelections, $selectedIndex)
            $fakePackage = $installPackages[$selectedIndex].PackageIdentifier -match $FakePackageExpression
            $hasPostInstall = $installPackages[$selectedIndex].PSobject.Properties.Name -contains "PostInstall"
            if ($fakePackage) {
                $title = $installPackages[$selectedIndex].PackageIdentifier
                $details = @("The selected package is not part of winget and only executes post-install comamnds.")
            } else {
                $commandArgs = @('show', $installPackages[$selectedIndex].PackageIdentifier)
                if (-not([string]::IsNullOrWhiteSpace($DefaultSource))) {
                    $commandArgs += @('--source', $DefaultSource)
                }
                $consoleEncoding = [console]::OutputEncoding
                [console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
                $details = @('')
                $details += winget $commandArgs --no-vt
                [console]::OutputEncoding = $consoleEncoding
                $fistLine = $details | Select-String -Pattern 'Found\s+(.*\[.*\])'
                $found = (($null -ne $fistLine) -and ($fistLine.Matches.Count -eq 1))
                if ($found) {
                    $title = $fistLine.Matches[0].Groups[1].Value
                    $details = $details[$fistLine.LineNumber..($details.Length - 1)]
                } else {
                    $title =$installPackages[$selectedIndex].PackageIdentifier
                }
            }
            if ($hasPostInstall) {
                $details += "`nPost-Install Commands:"
                $installPackages[$selectedIndex].PostInstall.Commands | ForEach-Object { $details += "`t$_" }
            }
            Show-Paginated -TextData $details -Title $title
            Hide-TerminalCursor
        }

        $TableUIArgs = @{
            Table = $installPackages
            Title = 'Select Software to Install'
            EnterKeyDescription = "Press ENTER to show selection details."
            EnterKeyScript = $showPackageDetailsScriptBlock
            DefaultMemberToShow = "PackageIdentifier"
            SelectedItemMembersToShow = @("PackageIdentifier", "Tags", "Version", "Location", "Interactive")
            Selections = ([ref]$selections)
        }

        Enter-AltScreenBuffer
        Hide-TerminalCursor
        Show-TableUI @TableUIArgs
        Exit-AltScreenBuffer

        if ($null -eq $selections) {
            $selectedPackages = @();
        } else {
            $selectedPackages = @($installPackages | Where-Object { $selections[$installPackages.indexOf($_)] })
        }
    } else {
        $selectedPackages = $installPackages
    }

    if ($selectedPackages.Count -eq 0) {
        Write-Output "No packages selected."
        return
    }

    $packageIndex = 0
    $errorCount = 0

    foreach ($installPackage in $selectedPackages)
    {
        Write-ProgressHelper -Packages $selectedPackages -PackageIndex $packageIndex

        $fakePackage = ($installPackage.PackageIdentifier -match $FakePackageExpression)
        if ($fakePackage) {
            Write-Verbose "command: (post-install only)"
        } else {
            Write-Verbose "command: winget install$(Get-WinGetSoftwareInstallArgs -Package $installPackage -UseLatest:$UseLatest)"
        }

        if ($PSCmdlet.ShouldProcess($installPackage.PackageIdentifier)) {
            Install-WinGetSoftware -Package $installPackage -ErrorCount ([ref]$errorCount)
        }

        Write-Output ""

        $packageIndex++
    }

    if ($errorCount -gt 0) {
        Write-Error "Done (Errors = $errorCount)."
    } else {
        Write-Output "Done."
    }
}

function Test-ObjectProperty
{
    param (
        [object]$Object,
        [string]$PropertyName
    )
    $PropertyName -in $Object.PSobject.Properties.Name
}

function Get-WinGetSoftwareInstallArgs
{
    param(
        [object]$Package,
        [switch]$UseLatest
    )

    if ($Interactive -or ((Test-ObjectProperty -Object $Package -Property "Interactive") -and $Package.Interactive)) {
        $interactiveArg = ' --interactive'
    } else {
        $interactiveArg = ''
    }

    $packageIdArg = " --id $($Package.PackageIdentifier)"

    if (-not([string]::IsNullOrWhiteSpace($DefaultSource))) {
        $sourceArg = " --source $DefaultSource"
    } else {
        $sourceArg = ''
    }

    if ((((Test-ObjectProperty -Object $Package -Property "VersionLock") -and $Package.VersionLock) -or -not($UseLatest)) -and
        (Test-ObjectProperty -Object $Package -Property "Version") -and -not([string]::IsNullOrWhiteSpace($Package.Version))) {
        $versionArg = " --version $($Package.Version)"
    } else {
        $versionArg = ''
    }

    if ((Test-ObjectProperty -Object $Package -Property "Location") -and -not([string]::IsNullOrWhiteSpace($Package.Location))) {
        $locationArg = " --location '$($Package.Location)'"
    } else {
        $locationArg = ''
    }

    if ((Test-ObjectProperty -Object $Package -Property "AdditionalArgs") -and -not([string]::IsNullOrWhiteSpace($Package.AdditionalArgs))) {
        $additionalArgs = " $($Package.AdditionalArgs)"
    } else {
        $additionalArgs = ''
    }

    return "$interactiveArg$packageIdArg$sourceArg$versionArg$locationArg$additionalArgs"
}

function Install-WinGetSoftware
{
    param(
        [object]$Package,
        [ref]$ErrorCount
    )

    $postInstallQuestion = "Run post-install command(s)?"
    $postInstallChoices = @(
        [System.Management.Automation.Host.ChoiceDescription]::new("&Yes", "Do run post-install command")
        [System.Management.Automation.Host.ChoiceDescription]::new("&No", "Do not run post-install command")
    )

    $runPostInstall = ($Package.PSobject.Properties.Name -contains "PostInstall")

    $fakePackage = $Package.PackageIdentifier -match $FakePackageExpression
    if ($fakePackage) {
        $installOk = $true
    } else {
        $installArgs = Get-WinGetSoftwareInstallArgs -Package $Package -UseLatest:$UseLatest
        Invoke-Expression "winget install $installArgs"

        $installOk = $LASTEXITCODE -eq 0
        Write-Verbose "returned: $LASTEXITCODE"
    }

    if (-not($installOk)) { $ErrorCount.Value++ }
    if (-not($runPostInstall)) { continue }

    if ($installOk) {
        if ($Package.PostInstall.Run -eq "Prompt") {
            $decision = $Host.UI.PromptForChoice($null, $postInstallQuestion, $postInstallChoices, 0)
            $runPostInstall = $runPostInstall -and ($decision -eq 0)
        } else {
            $runPostInstall = $runPostInstall -and (
                ($Package.PostInstall.Run -eq "Always") -or
                ($Package.PostInstall.Run -eq "OnSuccess"))
        }
    } else {
        if (($Package.PostInstall.Run -eq "Prompt") -or
            ($Package.PostInstall.Run -eq "PromptOnError")) {
            $decision = $Host.UI.PromptForChoice($null, $postInstallQuestion, $postInstallChoices, 1)
            $runPostInstall = $runPostInstall -and ($decision -eq 0)
        } else {
            $runPostInstall = $runPostInstall -and ($Package.PostInstall.Run -eq "Always")
        }
    }

    if (-not($runPostInstall)) { continue }

    Write-Output "Post-install: running ..."

    $terminatePostInstall = $false
    foreach ($cmd in $Package.PostInstall.Commands) {
        $runCommand = $true
        while ($runCommand) {
            $runCommand = $false
            Write-Verbose "executing: '$cmd'"
            $errorReult = $false
            try {
                $global:LASTEXITCODE = 0
                Invoke-Expression $cmd -ErrorVariable errorOutput
                $errorReult = ($LASTEXITCODE -ne 0) -or -not($?) -or -not([string]::IsNullOrEmpty($errorOutput))
            } catch {
                Write-Output "Last command encountered an error: $_"
                $errorReult = $true
            }

            if ($errorReult) {
                $ErrorCount.Value++
                if ($Package.PostInstall.OnError -eq "Skip") {
                    $terminatePostInstall = $true
                    break
                } elseif ($Package.PostInstall.OnError -eq "Prompt") {
                    $title = "An error occurred during the last post-install command"
                    $question = "What action should be performed?"
                    $choices = @(
                        [System.Management.Automation.Host.ChoiceDescription]::new("&Continue", "Continue with the next command")
                        [System.Management.Automation.Host.ChoiceDescription]::new("&Re-Run", "Re-run the last command")
                        [System.Management.Automation.Host.ChoiceDescription]::new("&Skip", "Skip the remaining commands for the current package")
                    )

                    $decision = $Host.UI.PromptForChoice($title, $question, $choices, 2)
                    $runCommand = $decision -eq 1
                    if ($decision -eq 2) { return }
                } else {
                    # "Continue", do nothing.
                }
            }
        }

        if ($terminatePostInstall) {
            break;
        }
    }

    Write-Output "Post-install: complete."
}

$RestoreTagScriptBlock = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    if (Test-Path $PackageDatabase) {
        $packages = Get-Content $PackageDatabase | ConvertFrom-Json
        if ($null -ne $packages) {
            $packages.Tags | Sort-Object -Unique | Where-Object { $_ -like "$wordToComplete*" }
        }
    }
}

Register-ArgumentCompleter -CommandName Restore-WingetSoftware -ParameterName Tag -ScriptBlock $RestoreTagScriptBlock
Register-ArgumentCompleter -CommandName Restore-WingetSoftware -ParameterName ExcludeTag -ScriptBlock $RestoreTagScriptBlock