msix.psm1

##############################################################################################################
# HELPER Functions
##############################################################################################################

function get-MsixAppXManifest {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string[]]  $sourcefile,
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 1
            )]
        [string[]]  $extractfolder
    )
    BEGIN {
    Add-Type -Assembly System.IO.Compression.FileSystem
    $item = Get-Item -Path $sourcefile
    }
    PROCESS {
    $zip = [IO.Compression.ZipFile]::OpenRead($($item.FullName))
    $zip.Entries | Where-Object {$_.Name -eq 'AppxManifest.xml'} | ForEach-Object {[System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$extractfolder\AppxManifest.xml", $true)}
    $zip.Dispose()
    }
    END {
    Clear-Variable sourcefile, extractfolder, item, zip
    }
}

function start-MsixProcess {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string[]]  $Process,
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 1
            )]
        [string[]]  $arguments
    )
    BEGIN {
    $item = Get-Item -Path $Process
    }
    PROCESS {
    $ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
    $ProcessInfo.FileName = $($item.FullName)
    $ProcessInfo.WorkingDirectory = Get-Location
    $ProcessInfo.RedirectStandardError = $true
    #$ProcessInfo.RedirectStandardOutput = $true #uncomment due to 4096 buffer issue
    $ProcessInfo.UseShellExecute = $false
    $ProcessInfo.Arguments = $arguments
    $MsixProcess = New-Object System.Diagnostics.Process
    $MsixProcess.StartInfo = $ProcessInfo
    $null = $MsixProcess.Start()
    $MsixProcess.WaitForExit()
    #$stdout = $MsixProcess.StandardOutput.ReadToEnd()
    $stderr = $MsixProcess.StandardError.ReadToEnd()
    $exitcode = $MsixProcess.ExitCode
    }
    END {
    return [pscustomobject]@{
    'stdout' = $stdout
    'stderr' = $stderr
    'exitcode' = $exitcode
    }
    Clear-Variable ProcessInfo, MsixProcess, stdout, stderr, process, arguments, item
    }
}

function start-MsixSigntool {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string[]]  $PackagePath,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 1
            )]
        [string[]]  $pfx,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 1
            )]
        [string[]]  $pfxpassword
    )
    BEGIN {
    $fileinfo = Get-Item $PackagePath
    if ($pfx){
    if (!($pfxpassword)){throw 'missing pfx password'}
    $cert = Get-Item -Path $pfx
    $arguments = "sign /v /tr http://timestamp.digicert.com /fd sha256 /f $($cert.FullName) /p $pfxpassword $($fileinfo.FullName)"
    }
    else {
    $arguments = "sign /v /tr http://timestamp.digicert.com /fd sha256 /a $($fileinfo.FullName)"
    }
    }
    PROCESS {
    $signing = start-MsixProcess -Process "$env:msixtool\tools\signtool.exe" -arguments $arguments
    if ($($signing.exitcode) -ne '0'){write-error -Message "signing went wrong: $($signing.stderr)" -RecommendedAction "please check eventlog Microsoft\Windows\AppxPackagingom"}
    }
    END {
    Clear-Variable fileinfo, PackagePath
    }

}

function new-MsixPsfJson {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string[]] $AppxManiFest
    )
    BEGIN {
    $manifest = get-item $AppxManiFest
    [xml]$appinfo = Get-Content -Path $($manifest.Fullname)
    }
    PROCESS {
    $applications = $appinfo.Package.Applications.Application
    $appjson = foreach ($app in $applications){
    [pscustomobject]@{
    'id' = $app.id
    'executable' = $app.executable.replace('\','/')
    }
    }

    [pscustomobject]@{
    'applications' = $appjson
    'processes' = $procesjson
    }|ConvertTo-Json

    }
    END {
    Clear-Variable appjson, applications, app, appinfo, manifest
    }
}
###################################################################################################
#REGULAR Functions
###################################################################################################
Function Get-MsixInfo {
<#
.SYNOPSIS
    Get msix info for a specific package
 
 
.NOTES
    Name: Get-MsixInfo
    Author: Sander de Wit
    Version: 1.0
    DateCreated: 04-05-2021
 
 
.EXAMPLE
    Get-MsixInfo -PackagePath c:\temp\app.msix
 
.EXAMPLE
    Get-MsixInfo -PackagePath c:\temp\app.msix -detailed
 
#>


    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string[]]  $PackagePath,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $false,
            Position = 1
            )]
        [switch] $detailed

    )

    BEGIN {
    $fileinfo = Get-Item -Path $PackagePath
    $tempdir = "$env:temp\msix\$($fileinfo.BaseName)"
    if (!(Test-Path -Path $tempdir)){
    $null = New-Item -ItemType Directory -force -path $tempdir}
    else
    {
    Write-Verbose "temp directory already unpacked, cleaning up"
    Remove-Item -Path $tempdir\* -Force -Recurse}
    try {
    write-verbose 'calling makeappx to unpack package'
    #$null = .\Tools\MakeAppx.exe unpack /p $($fileinfo.FullName) /d $tempdir /o
    get-MsixAppXManifest -sourcefile $fileinfo.FullName -extractfolder $tempdir
    }catch {
    Write-Error "unable to extract the msix package"
    }
    }

    PROCESS {
    Write-Verbose "reading $($tempdir)\AppxManifest.xml"
    [xml]$appinfo = Get-Content -Path "$tempdir\AppxManifest.xml"
    Write-Verbose 'getting signature information'
    $signinfo = Get-AuthenticodeSignature -FilePath $fileinfo

    $info = @()
    $info += [pscustomobject]@{
    'name' = $($appinfo.Package.Identity.Name)
    'DisplayName' = $($appinfo.Package.Properties.DisplayName)
    'Publisher' = $($appinfo.Package.Identity.Publisher)
    'PublisherDisplayName' = $($appinfo.Package.Properties.PublisherDisplayName)
    'Version' = $($appinfo.Package.Identity.Version)
    'ProcessorArchitecture' = $($appinfo.Package.Identity.ProcessorArchitecture)
    'Description' = $($appinfo.Package.Properties.Description)
    'Signed' = $($signinfo.Status)
    'SignedBy' = $($signinfo.SignerCertificate.Subject)
    'ThumbPrint' = $($signinfo.SignerCertificate.Thumbprint)
    'TimeStampCertificate' = $($signinfo.TimeStamperCertificate)
    }
    if ($detailed)
        {
         $info += $($appinfo.Package.Applications.Application)
        }

    return $info
    }

    END {
    Write-Verbose "cleaning up"
    Remove-Item -Path $tempdir -Force -Recurse
    }
}

Function start-MsixCmd {
<#
.SYNOPSIS
    start command in specific package
 
 
.NOTES
    Name: start-MsixCmd
    Author: Sander de Wit
    Version: 1.0
    DateCreated: 04-05-2021
 
 
.EXAMPLE
    start-MsixCmd -PackageName npp -command notepad.exe
 
.EXAMPLE
    start-MsixCmd -PackageName npp
 
#>


    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string[]]  $PackageName,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string]  $command = 'cmd.exe'
    )

    BEGIN {
    try {
    $appx = Get-AppxPackage -Name $PackageName
    }
    catch {
    $appx = Get-AppxPackage|Where-Object {$_.name -like "*$($PackageName)*"}
    }
    if ($appx.count -gt '1'){ throw ('multiple applications match the criteria')}
    $AppXManifest = Get-AppPackageManifest -Package $($appx.PackageFullName)
    $PackageFamilyName = $($AppX.PackageFamilyName)
    $apps = $($AppXManifest.Package.Applications.Application)
    if ($apps.count -gt '1'){Write-Error -Message "multiple apps found, selecting app 1 $($apps[0].Id)"
    $appId = $apps[0].Id}
    else {$appId = $apps.Id}
    }

    PROCESS {
    Invoke-CommandInDesktopPackage -PackageFamilyName $PackageFamilyName -PreventBreakaway -command $command -AppId $appId
    }

    END {
    Clear-Variable appx, packagename, AppXManifest
    }
}

Function update-MsixSigner {
<#
.SYNOPSIS
    signs MSIX with new certificate and updates publisher.
 
 
.NOTES
    Name: update-MsixSigner
    Author: Sander de Wit
    Version: 1.0
    DateCreated: 04-05-2021
 
 
.EXAMPLE
    update-MsixSigner -PackagePath app.msix -publisher 'OU=Demo, O=Demo, C=NL' -pfx 'signer.pfx' -pfxpassword Password
 
.EXAMPLE
    update-MsixSigner -PackagePath app.msix -publisher 'OU=Demo, O=Demo, C=NL'
 
#>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string[]]  $PackagePath,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 1
            )]
        [string[]]  $publisher,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 2
            )]
        [string[]]  $pfx,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 3
            )]
        [string[]]  $pfxpassword
    )

    BEGIN {
    if (!($env:msixtool)){throw 'user environmental variable Msixtool not found, please run prep-environment.ps1'}
    Write-Verbose -Message "unpacking msix to temp folder"
    $fileinfo = Get-Item -Path $PackagePath
    $tempdir = "$env:temp\msix\$($fileinfo.BaseName)"
    if (!(Test-Path -Path $tempdir)){
    $null = New-Item -ItemType Directory -force -path $tempdir}
    else
    {
    Write-Verbose -Message "temp directory already unpacked, cleaning up"
    Remove-Item -Path $tempdir\* -Force -Recurse}
    write-verbose -Message 'calling makeappx to unpack package'
    $null = start-MsixProcess -Process "$env:msixtool\Tools\MakeAppx.exe" -arguments "unpack /p $($fileinfo.FullName) /d $tempdir /o"
    }

    PROCESS {
    #modify to AppXManifest when necessary
    Write-Verbose -Message "reading $($tempdir)\AppxManifest.xml"
    [xml]$appinfo = Get-Content -Path "$tempdir\AppxManifest.xml"
    if ($publisher)
     {
        if ($($appinfo.Package.Identity.Publisher) -ceq $publisher)
            {
             Write-Output -InputObject "not changing the publisher, as it is already a match"
             #Microsoft MSIX team recommends to use of signtool over powershell Get-AuthenticodeSignature
             start-Msixsigntool -PackagePath $($fileinfo.FullName) -pfx $pfx -pfxpassword $pfxpassword
        }
        else
        {
         $appinfo.Package.Identity.Publisher = [string]$publisher
         Write-Output -InputObject "modifying msix publisher"
         $appinfo.Save("$tempdir\AppxManifest.xml")
         Write-Output -InputObject "packing up MSIX again"
         $null = start-MsixProcess -Process "$env:msixtool\tools\MakeAppx.exe" -arguments "pack /p $($fileinfo.FullName) /d $tempdir /o"
         start-Msixsigntool -PackagePath $($fileinfo.FullName) -pfx $pfx -pfxpassword $pfxpassword
        }
     }
     #no publisher specified
    else {
     start-Msixsigntool -PackagePath $($fileinfo.FullName) -pfx $pfx -pfxpassword $pfxpassword
     }
    }
    END {
    Write-Verbose -Message "cleaning up"
    Remove-Item -Path $tempdir -Force -Recurse
    Remove-Variable fileinfo, appinfo
    }
}

Function add-MsixPsf {
<#
.SYNOPSIS
    adds to Package Support Framework to msix package
 
 
.NOTES
    Name: add-MsixPsf
    Author: Sander de Wit
    Version: 1.0
    DateCreated: 05-05-2021
 
 
.EXAMPLE
    add-MsixPsf -PackagePath npp.msix
 
#>


    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
            )]
        [string[]]  $PackagePath,
        [string[]]  $pfx,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 3
            )]
        [string[]]  $pfxpassword
    )

    BEGIN {
    $fileinfo = Get-Item -Path $PackagePath
    $tempdir = "$env:temp\msix\$($fileinfo.BaseName)"
    if (!(Test-Path -Path $tempdir)){
    $null = New-Item -ItemType Directory -force -path $tempdir}
    else
    {
    Write-Verbose -Message "temp directory already unpacked, cleaning up"
    Remove-Item -Path $tempdir\* -Force -Recurse}
    write-verbose -Message 'calling makeappx to unpack package'
    $unpack = start-MsixProcess -Process "$env:msixtool\Tools\MakeAppx.exe" -arguments "unpack /p $($fileinfo.FullName) /d $tempdir /o"
    if ($unpack.exitcode -ne '0'){Write-Error -Message "something went wrong: $($unpack.stderr)"}
    }
    PROCESS {
    #reading AppXManifest to find applications
    Write-Verbose -Message "reading $($tempdir)\AppxManifest.xml"
    [xml]$appinfo = Get-Content -Path "$tempdir\AppxManifest.xml"
    Write-Verbose -Message "generating config.json"
    if ($appinfo.Package.Applications.Application.gettype().name -eq 'XMLElement'){
    $appfolder = "$tempdir\$($appinfo.Package.Applications.Application.Executable.substring(0,$($appinfo.Package.Applications.Application.Executable.LastIndexOf('\'))))"
    }
    else{
    $appfolder = "$tempdir\$($appinfo.Package.Applications.Application[0].Executable.substring(0,$($appinfo.Package.Applications.Application[0].Executable.LastIndexOf('\'))))"
    }
    if($PSCmdlet.ShouldProcess("$appfolder\config.json", "Writing config.json")){
    new-MsixPsfJson -AppxManiFest "$tempdir\AppxManifest.xml"|Out-File "$appfolder\config.json"
    }
    #copy items to relevant folders
    Write-Verbose "copying PSF files, add check for x86 or x64"
    if($PSCmdlet.ShouldProcess("psfrundll64.exe, psfruntime64.dll", "copying Psf files")){
    Copy-Item "$env:msixtool\PSF\PsfRunDll64.exe" $appfolder
    Copy-Item "$env:msixtool\PSF\PsfRuntime64.dll" $appfolder
    }
    $i = 0
    foreach ($application in $appinfo.Package.Applications.Application){
    $i++
        if ($i -gt '1'){
        if($PSCmdlet.ShouldProcess($application.Executable.replace($application.Executable.split('\')[-1],"PsfLauncher$($I).exe"), "copying and adding in manifest")){
         Copy-Item -Path "$env:msixtool\PSF\PsfLauncher64.exe" -Destination "$appfolder\PsfLauncher$($I).exe"
         $application.Executable =  $application.Executable.replace($application.Executable.split('\')[-1],"PsfLauncher$($I).exe")
         }
        }
        else
        {
        if($PSCmdlet.ShouldProcess($application.Executable.replace($application.Executable.split('\')[-1],"PsfLauncher.exe"), "copying and adding in manifest")){
         $application.Executable = $application.Executable.replace($application.Executable.split('\')[-1],"PsfLauncher.exe")
         Copy-Item -Path "$env:msixtool\PSF\PsfLauncher64.exe" -Destination "$appfolder\PsfLauncher.exe"
         }
        }
    }
    if($PSCmdlet.ShouldProcess("AppXManifest.XML", "updating manifest")){
    $appinfo.Save("$tempdir\AppxManifest.xml")
    }
    Write-Output "opening config.json for verification/modifcation"
    if($PSCmdlet.ShouldProcess("$appfolder\config.json", "invoking notepad")){
    Start-Process -FilePath 'notepad.exe' -Wait -ArgumentList "$appfolder\config.json"
    Write-Output "validating config.json"
    try {$null = get-content -Path "$appfolder\config.json"|ConvertFrom-Json}
    catch {Write-Error "invalid json"}
    }
    #pack application again
     Write-Output -InputObject "packing up MSIX again"
     if($PSCmdlet.ShouldProcess("packaging to msix", "invoke makeappx")){
     $null = start-MsixProcess -Process "$env:msixtool\tools\MakeAppx.exe" -arguments "pack /p $($fileinfo.FullName) /d $tempdir /o"
     }
     if($PSCmdlet.ShouldProcess("signing msix", "invoke signtool")){
     start-Msixsigntool -PackagePath $($fileinfo.FullName) -pfx $pfx -pfxpassword $pfxpassword
     }

    }
    END {
    Write-Verbose -Message "cleaning up"
    Remove-Item -Path $tempdir -Force -Recurse
    Remove-Variable fileinfo, appinfo
    }
}


Export-ModuleMember -Function get-MsixAppXManifest, start-MsixProcess, start-MsixSigntool, Get-MsixInfo, update-MsixSigner, start-MsixCmd, Add-MsixPsf