JT.AppxDownloader.psm1

#Requires -Modules @{ ModuleName='JT.WriteLog'; ModuleVersion='2.0.0.3' }

<# JT.AppxDownloader : AxD #>
$_setting = @{
    'DebugMode' = $false
}

$PSDefaultParameterValues['Write-Log:LengthOfFunctionName'] = 24
$PSDefaultParameterValues['Write-Log:LogFile']              = ''
$PSDefaultParameterValues['Write-Log:DebugMode']            = $_setting.DebugMode

# ------------------------------------------------------------
# MARK: Get appx list from cache
# Date: 2025.03.02
# ------------------------------------------------------------
function Get-AxDCachedList { Return $Script:AppxPackageList }
If ($_setting.DebugMode) { Export-ModuleMember -Function Get-AxDCachedList }

# ------------------------------------------------------------
# MARK: Get Appx offline files list
# Date: 2025.03.02
# ------------------------------------------------------------
function Get-AxDOfflineFileList {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    
    begin {
        
    }
    
    process {
        If (Test-Path $Path) {
            $_appxFileList = @{}
            Write-Log -ErrorLevel DEBUG -Message "Collect Appx files list from '$($Path)' folder"
            $packageExtension = @('*.appx*', '*.msix*')
            foreach ($extension in $packageExtension) {
                Write-Log -ErrorLevel DEBUG -Message "> Collecting extension files [$($extension)]"
                foreach ($packageFile in (Get-ChildItem -Path $Path -Filter $extension -File)) {
                    $fileInfo = $packageFile.Name.Split('_')
                    If ($fileInfo.Count -gt 2) {
                        Write-Log -ErrorLevel DEBUG -Message " - $($fileInfo[0]):$($fileInfo[1])"
                        $_appxFileList[$fileInfo[0]] = @{'Version'=[version]$fileInfo[1]; 'Filename'=$packageFile.Name}
                    } Else {
                        Write-Log -ErrorLevel WARNING -Message " - Filename format incorrect: $($packageFile.Name)"
                    }
                }
            }
            Write-Log -ErrorLevel DEBUG -Message "> Total $($_appxFileList.Count) files found."
            Return $_appxFileList
        } Else {
            Write-Log -ErrorLevel ERROR -Message "The network path '$($Path)' does not exist"
            Return $null
        }
    }
    
    end {
        
    }
}
If ($_setting.DebugMode) { Export-ModuleMember -Function Get-AxDOfflineFileList}

# ------------------------------------------------------------
# MARK: Get the package list from Internet (using Microsoft Apps 'ProductID')
# Date: 2025.03.02
# ------------------------------------------------------------
function Get-AxDPackageList {
    <#
    .SYNOPSIS
    Get-AxDPackageList is a tool for get the package list (by ProductID)
 
    .PARAMETER ProductID
    ProductID
 
    .EXAMPLE
    Get-AxDPackageList -ProductID '9MSMLRH6LZF3'
 
    Get the package list for Microsoft Notepad (https://www.microsoft.com/store/productId/9MSMLRH6LZF3)
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$ProductID,
        [Parameter(DontShow = $true)]
        [string]$_counter
    )
    
    begin {
        
    }
    
    process {
        Write-Log -ErrorLevel INFO -Message "($($_counter)) Get packing file list from ProductID: $($ProductID)".Replace('() ', '')
        $_PackageURL = "https://www.microsoft.com/store/productId/" + $ProductID
        Write-Log -ErrorLevel DEBUG -Message "> URL: $($_PackageURL)"
        $_retryCount = 0
        # Download package list by (Microsoft) ProductID
        Do {
            Write-Log -ErrorLevel DEBUG -Message "> Download the package list from the Internet (Retry:$($_retryCount)) .....".Replace('(Retry:0) ', '')
            $WebResponse = Invoke-WebRequest -UseBasicParsing -Method 'POST' -Uri 'https://store.rg-adguard.net/api/GetFiles' -Body "type=url&url=$($_PackageURL)&ring=Retail" -ContentType 'application/x-www-form-urlencoded' -ErrorAction SilentlyContinue -ErrorVariable WebError
            If ($WebResponse) {
                #$MatchLinks = @($WebResponse.Links | Where-Object {($_ -like '*.appx*') -or ($_ -like '*.msix*')} | Where-Object {$_ -like '*_neutral_*' -or $_ -like "*_"+$env:PROCESSOR_ARCHITECTURE.Replace("AMD","X").Replace("IA","X")+"_*"})
                $MatchLinks = @($WebResponse.Links | Where-Object {($_ -like '*.appx*') -or ($_ -like '*.msix*')} | Where-Object {$_ -like '*_neutral_*' -or $_ -like "*_x64_*"})
            } ElseIf ($webError) {
                Write-Log -ErrorLevel WARNING -Message "$($WebError.Expection.Message)"
            } Else {
                Write-Log -ErrorLevel ERROR -Message "Unknow error!!"
                Return $null
            }
        } Until (($MatchLinks.Count -gt 0) -or ($_retryCount -gt 2))
        If ($MatchLinks.Count -gt 0) {
            Write-Log -ErrorLevel DEBUG -Message "> Total ($($MatchLinks.Count)) links found."
        } Else {
            Return $null
        }
        
        # Extract link from downloaded package list
        $_PackageList = @()
        foreach ($_link in $MatchLinks) {
            $_PackageInfo = [PSCustomObject]@{
                Filename = ($_link | Select-String -Pattern '(?<=rel="noreferrer">).+(?=</a>)').Matches.Value
                Url      = ($_link | Select-String -Pattern '(?<=a href=").+(?=" r)').Matches.Value
                Name     = ''
                Version  = ''
            }
            $_PackageInfo.Name    = $_PackageInfo.Filename.Split('_')[0]
            $_PackageInfo.Version = $_PackageInfo.Filename.Split('_')[1]
            Write-Log -ErrorLevel DEBUG -Message " - Name:$($_PackageInfo.Name); Version:$($_PackageInfo.Version)"
            $_PackageList += $_PackageInfo
        }
        Return $_PackageList
    }
    
    end {
        
    }
}
Export-ModuleMember -Function Get-AxDPackageList

# ------------------------------------------------------------
# MARK: Collect Appx files list
# Date: 2025.03.02
# ------------------------------------------------------------
function Invoke-AxDFileDownload {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$Uri,
        [Parameter(Mandatory = $true)]
        [string]$Destination,
        [Parameter(DontShow = $true)]
        [string]$_counter = ''
    )
    
    begin {
        If (Test-Path (Split-Path -Path $Destination -Parent)) {
            If (Test-Path $Destination) {
                Write-Log -ErrorLevel WARNING -Message "The file '$($Destination)' was exist."
                $_temp = $Destination.Split('.')
                $_checkCount = 0
                If ($_temp.Count -le 2) {
                    $_basename = $Destination
                    $_ext      = ''
                } Else {
                    $_basename = $_temp[0..$($_temp.Count - 2)]
                    $_ext      = $($_temp[-1])
                }
                Do {
                    $_checkCount++
                    $Destination = "$($_basename) ($($_checkCount)).$($_ext)"
                } Until (-not (Test-Path $Destination))
                Write-Log -ErrorLevel WARNING -Message "> The destination file was renamd to '$($Destination)'."
            }
        } Else {
            Write-Log -ErrorLevel ERROR -Message "The destination path '$(Split-Path -Path $Destination -Parent)' does not exist."
            Return $null
        }
    }
    
    process {
        Write-Log -ErrorLevel DEBUG -Message "(0)Downloading file $(Split-Path $Destination -Leaf) .....".Replace('(0)', $_counter)
        $_downloader = New-Object System.Net.WebClient
        $_downloader.DownloadFileAsync($Uri, $Destination)
        $_StartTime = Get-Date
        $_baseFilename = Split-Path -Path $Destination -Leaf
        While ($_downloader.IsBusy) {
            Start-Sleep -Seconds 1
            $_timeDiff = (Get-Date) - $_StartTime
            #If ($_timeDiff.TotalSeconds -gt 5) {
                Write-Progress -Activity "(0)Downloading file $($_baseFilename) .....".Replace('(0)', $_counter) -Status $_timeDiff.ToString().Substring(0,8) -id 1
            #}
        }
        Write-Progress -Id 1 -Completed -Activity "(0)Downloading file $($_baseFilename) .....".Replace('(0)', $_counter)
        If ((Get-Item $Destination).Length -ne 0) {
            Write-Log -ErrorLevel SUCCESS -Message "(0)Downloaded file: $($_baseFilename)".Replace('(0)', $_counter)
            Return $Destination
        } Else {
            Write-Log -ErrorLevel ERROR -Message "Failure to download file: $($_baseFilename)"
            Write-Log -ErrorLevel ERROR -Message "- $($Error[0].Exception.Message)"
            Return $null
        }
    }
    
    end {
        
    }
}
If ($_setting.DebugMode) { Export-ModuleMember -Function Invoke-AxDFileDownload }

# ------------------------------------------------------------
# MARK: Download latest WinGet package
# Date: 2025.03.02
# ------------------------------------------------------------
function Invoke-AxDWinGetDownload {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    
    begin {
        $_Url = 'https://api.github.com/repos/microsoft/winget-cli/releases/latest'
    }
    
    process {
        $_retryCount = 0
        Write-Log -ErrorLevel INFO -Message 'Update WinGet package'
        Do {
            Write-Log -ErrorLevel DEBUG -Message "> Checking the latest version of WinGet file ($($_retryCount)) .....".Replace(' (0)', '')
            $_result = Invoke-WebRequest -Uri $_Url -ErrorAction SilentlyContinue -ErrorVariable WebError
            If ($_result) {
                Write-Log -ErrorLevel DEBUG -Message (" Latest version: {0}" -f  ($_result.Content | ConvertFrom-Json).tag_name.Replace('v', ''))
                $_Pkg = ($_result.Content | ConvertFrom-Json).assets | Where-Object { $_.Name -ilike '*.msixbundle'}
            } Else {
                $_retryCount++
            }
        } Until ($_result -or ($_retryCount -gt 3))

        If ($_Pkg) {
            $Destination = "$($Path)\$($_Pkg.Name)"
            $_oldFile    = $_Pkg.Name.Replace('.msixbundle', '.old')
            If (Test-Path $Destination) {
                If ((Get-Item -Path $Destination).Length -ne $_Pkg.size) {
                    Write-Log -ErrorLevel DEBUG -Message "> The original file will be rename to '$($_oldFile)'."
                    Rename-Item -Path $Destination -NewName $_oldFile -ErrorAction SilentlyContinue -ErrorVariable RenameError | Out-Null
                    If ($RenameError) {
                        Write-Log -ErrorLevel ERROR -Message 'Failure to rename file'
                        Write-Log -ErrorLevel ERROR -Message "> $($RenameError.Exception.Message)"
                        Return $null
                    }
                } Else {
                    Write-Log -ErrorLevel DEBUG -Message '> File exist and no need to update.'
                    Return $null
                }
            }
            If ((Invoke-AxDFileDownload -Uri $_Pkg.browser_download_url -Destination $Destination -_counter '- ')) {
                If (Test-Path -Path "$($Path)\$($_oldFile)") {
                    Write-Log -ErrorLevel DEBUG -Message 'Delete old file'
                    Remove-Item -Path "$($Path)\$($_oldFile)" -Force
                }
                Return $Destination
            } Else {
                Write-Log -ErrorLevel DEBUG -Message 'Rollbak old file'
                Rename-Item -Path "$($Path)\$($_oldFile)" -NewName $_Pkg.Name
                Return $null
            }
        }
    }
    
    end {
        
    }
}
If ($_setting.DebugMode) { Export-ModuleMember -Function Invoke-AxDWinGetDownload }

# ------------------------------------------------------------
# MARK: Update Appx offline files from Internet
# Date: 2025.03.02
# ------------------------------------------------------------
function Update-AxDOfflineFile{
    <#
    .SYNOPSIS
    Update-AxDOfflineFile is download updated appx/misx package from the Internet.
 
    .PARAMETER AppxList
    Appx list (CSV file format).
 
    CSV file Header: AppName,PackageIdentifier
 
    .PARAMETER Path
    The downloaded folder location.
    If the folder contain another appx/misx file, it will compare the file version before download and repalce it.
 
    .EXAMPLE
    Update-AxDOfflineLine -AppxList 'C:\Temp\AppxList.csv' -Path 'C:\Temp\DownloadedPackage'
     
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$AppxList,
        [string]$Path = $PWD.ToString(),
        [Parameter(DontShow = $true)]
        [switch]$EnableLogging,
        [Parameter(DontShow = $true)]
        [switch]$DebugMode = $_setting.DebugMode
    )
    
    begin {
        Update-WriteLogSetting -EnableLogging:$EnableLogging -DebugMode:$DebugMode
    }
    
    process {
        # Get the appx list for download
        Write-Log -ErrorLevel DEBUG -Message 'Import CSV file .....'
        If (Test-Path $AppxList) {
            $_targetList = Import-Csv $AppxList
            Write-Log -ErrorLevel DEBUG -Message "> Total $($_targetList.Count) appx from the list."
        } Else {
            Write-Log -ErrorLevel ERROR -Message 'The CSV file not exist.'
        }
        If ($_targetList.Count -eq 0) {
            Write-Log -ErrorLevel WARNING -Message 'No appx was listed from the CSV file.'
        }

        # Collect the existing appx file under 'Destination' folder
        $Script:AppxPackageList = Get-AxDOfflineFileList -Path $Path
        If (-not $Script:AppxPackageList) {
            Return $null
        }

        # Get the package list from Internet and download package (if need)
        $_DownloadedList = @()
        $_Processing = 1
        foreach ($_tList in $_targetList) {
            $_PkgList = Get-AxDPackageList -ProductID $_tList.PackageIdentifier -_counter "$($_Processing)/$($_targetList.Count)"
            $_UpdateList = @()
            # Comparing verison between existing package and downloaded list
            foreach ($_Pkg in $_PkgList) {
                If (-not $Script:AppxPackageList.ContainsKey($_Pkg.Name)) { 
                    $Script:AppxPackageList["$($_Pkg.Name)"] = @{'Version' = [Version]'0.0.0.0'}
                }
                If ($_Pkg.Version -gt $Script:AppxPackageList."$($_Pkg.Name)".Version) {
                    $Script:AppxPackageList."$($_Pkg.Name)"['Url']     = $_Pkg.Url
                    $Script:AppxPackageList."$($_Pkg.Name)"['NewFile'] = $_Pkg.Filename
                    $Script:AppxPackageList."$($_Pkg.Name)".Version = $_Pkg.Version
                    $_UpdateList += $_Pkg.Name
                }
            }

            # Download new package from the Internet
            $_UpdateList = $_UpdateList | Select-Object -Unique
            $_Downloading = 1
            foreach ($_NewPkg in ($_UpdateList)) {
                # Download file
                $_result = Invoke-AxDFileDownload -Uri $Script:AppxPackageList.$_NewPkg.Url -Destination "$($Path)\$($Script:AppxPackageList.$_NewPkg.NewFile)" -_counter "- ($($_Downloading)/$($_UpdateList.Count)) "
                If ($_result) {
                    $_oldFile = "$($Path)\$($Script:AppxPackageList.$_NewPkg.'Filename')"
                    If ((Test-Path $_oldFile -PathType Leaf) -and ($Script:AppxPackageList.$_NewPkg.'Filename')) {
                        Remove-Item -Path $_oldFile -Force -ErrorAction SilentlyContinue -ErrorVariable RemoveError | Out-Null
                        If ($RemoveError) {
                            Write-Log -ErrorLevel WARNING -Message "Failure to remove old package file: $($Script:AppxPackageList.$_NewPkg.'Filename')"
                            Write-Log -ErrorLevel WARNING -Message "> $($RemoveError.Exception.Message)"
                        } Else {
                            Write-Log -ErrorLevel DEBUG -Message "Old package file '$($Script:AppxPackageList.$_NewPkg.'Filename')' was removed."
                        }
                    }
                    $Script:AppxPackageList.$_NewPkg.'Filename' = Split-Path -Path $_result -Leaf
                    $Script:AppxPackageList.$_NewPkg.Remove('Url')
                    $Script:AppxPackageList.$_NewPkg.Remove('NewFile')
                    $_DownloadedList += $_NewPkg
                }
                $_Downloading++
            }

            $_Processing++
        }

        $_DownloadedList += (Invoke-AxDWinGetDownload -Path $Path)

        If ($_DownloadedList) {
            If ($_DownloadedList.Count -eq 1) {
                Write-Log -ErrorLevel SUCCESS -Message 'Total 1 file was updated.'
            } Else {
                Write-Log -ErrorLevel SUCCESS -Message "Total $($_DownloadedList.Count) files were updated."
            }
        } Else {
            Write-Log -ErrorLevel INFO -Message 'No file was updated.'
        }
    }
    
    end {
        
    }
}
Export-ModuleMember -Function Update-AxDOfflineFile

# ------------------------------------------------------------
# MARK: (Private function) Update 'Write-Log' setting
# Date: 2025.02.22
# ------------------------------------------------------------
function Update-WriteLogSetting {
    [CmdletBinding()]
    param (
        [switch]$EnableLogging,
        [switch]$DebugMode
    )
    
    process {
        If ($EnableLogging) {
            If ($Global:PSDefaultParameterValues.ContainsKey('Write-Log:logPath')) {
                $PSDefaultParameterValues['Write-Log:logPath'] = $Global:PSDefaultParameterValues.'Write-Log:logPath'
            } Else {
                $PSDefaultParameterValues['Write-Log:logPath'] = './Log'
            }
        } Else {
            $PSDefaultParameterValues['Write-Log:logPath'] = ''
        }

        If ($DebugMode) {
            $PSDefaultParameterValues['Write-Log:debugMode'] = $true
        } Else {
            $PSDefaultParameterValues['Write-Log:debugMode'] = $false
        }
    }
}