Public/Save-StoreApp.ps1

function Save-StoreApp {
    <#
    .SYNOPSIS
        Downloads Windows Store packages to disk.
    .DESCRIPTION
        Accepts package metadata from Get-StoreLinks (via pipeline or parameter) and
        downloads each file. Validates SHA1 after download and skips files that already
        exist with matching name and size.
 
        Supports -WhatIf and -Confirm via ShouldProcess.
    .PARAMETER InputObject
        One or more package objects from Get-StoreLinks. Accepts pipeline input.
    .PARAMETER Path
        The output directory. Defaults to the current directory. Created if it does not exist.
    .PARAMETER Filter
        A wildcard pattern to filter which packages to download by filename.
        For example, '*.appx', '*.msixbundle', '*x64*'.
    .OUTPUTS
        System.IO.FileInfo for each successfully downloaded file.
    .EXAMPLE
        Get-StoreLinks -Data '9NBLGGH4NNS1' | Save-StoreApp -Path ./downloads
        Downloads all Retail-channel packages for the given product ID.
    .EXAMPLE
        Get-StoreLinks -Data '9NBLGGH4NNS1' -Channel All | Save-StoreApp -Filter '*.msixbundle' -Path C:\Packages
        Downloads only .msixbundle files from all channels.
    .EXAMPLE
        Get-StoreLinks -Data '9NBLGGH4NNS1' | Save-StoreApp -WhatIf
        Shows what would be downloaded without actually downloading.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSCustomObject]$InputObject,

        [Parameter()]
        [string]$Path = '.',

        [Parameter()]
        [string]$Filter
    )

    begin {
        $resolvedPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path)
        if (-not (Test-Path -LiteralPath $resolvedPath -PathType Container)) {
            Write-Verbose "Creating output directory: $resolvedPath"
            $null = New-Item -Path $resolvedPath -ItemType Directory -Force -ErrorAction Stop
        }

        $queue = [System.Collections.Generic.List[PSCustomObject]]::new()
    }

    process {
        if ($Filter -and ($InputObject.Name -notlike $Filter)) {
            Write-Verbose "Skipping (filter mismatch): $($InputObject.Name)"
            return
        }
        $queue.Add($InputObject)
    }

    end {
        $total = $queue.Count
        if ($total -eq 0) {
            Write-Verbose 'No packages matched the filter.'
            return
        }

        for ($i = 0; $i -lt $total; $i++) {
            $pkg = $queue[$i]
            $fileName = $pkg.Name
            $outFile = Join-Path -Path $resolvedPath -ChildPath $fileName

            Write-Progress -Activity 'Downloading Store packages' `
                -Status "$fileName ($($i + 1) of $total)" `
                -PercentComplete ([math]::Floor(($i / $total) * 100))

            # Skip if file already exists with matching size.
            if (Test-Path -LiteralPath $outFile -PathType Leaf) {
                $existing = Get-Item -LiteralPath $outFile
                $expectedBytes = Convert-SizeToBytes -SizeString $pkg.Size

                if ($null -ne $expectedBytes -and $existing.Length -eq $expectedBytes) {
                    Write-Verbose "Skipping (exists, size matches): $fileName"
                    Write-Output $existing
                    continue
                } elseif ($null -eq $expectedBytes) {
                    Write-Verbose "Skipping (exists, size not parseable): $fileName"
                    Write-Output $existing
                    continue
                }
                Write-Verbose "File exists but size mismatch (expected ~$expectedBytes, got $($existing.Length)). Re-downloading."
            }

            if (-not $PSCmdlet.ShouldProcess($fileName, 'Download')) {
                continue
            }

            Write-Verbose "Downloading: $fileName -> $outFile"
            try {
                $dlParams = @{
                    Uri             = $pkg.URL
                    OutFile         = $outFile
                    UseBasicParsing = $true
                    ErrorAction     = 'Stop'
                }
                Invoke-WebRequest @dlParams
            } catch {
                Write-Error "Failed to download ${fileName}: $_"
                continue
            }

            # SHA1 validation.
            if ($pkg.SHA1 -and $pkg.SHA1.Trim().Length -eq 40) {
                Write-Verbose "Validating SHA1 for $fileName..."
                $actualHash = (Get-FileHash -LiteralPath $outFile -Algorithm SHA1).Hash
                if ($actualHash -ne $pkg.SHA1.ToUpper()) {
                    Write-Warning "SHA1 mismatch for ${fileName}: expected $($pkg.SHA1), got $actualHash"
                } else {
                    Write-Verbose "SHA1 validated: $actualHash"
                }
            }

            Write-Output (Get-Item -LiteralPath $outFile)
        }

        Write-Progress -Activity 'Downloading Store packages' -Completed
    }
}

function Convert-SizeToBytes {
    <#
    .SYNOPSIS
        Converts a human-readable size string to bytes.
    #>

    [CmdletBinding()]
    [OutputType([long])]
    param(
        [Parameter()]
        [string]$SizeString
    )

    if ([string]::IsNullOrWhiteSpace($SizeString)) { return $null }

    if ($SizeString -match '^([\d,.]+)\s*(Bytes?|KB|MB|GB|TB)?$') {
        $num = [double]($Matches[1] -replace ',', '')
        $unit = if ($Matches[2]) { $Matches[2] } else { 'Bytes' }
        switch -Wildcard ($unit) {
            'Byte*' { return [long]$num }
            'KB' { return [long]($num * 1KB) }
            'MB' { return [long]($num * 1MB) }
            'GB' { return [long]($num * 1GB) }
            'TB' { return [long]($num * 1TB) }
        }
    }

    return $null
}