MSIX.Distribution.ps1

# =============================================================================
# Distribution helpers (issues #117, #118)
# -----------------------------------------------------------------------------
# The step after post-processing: hand the signed .msix to users.
# New-MsixAppInstallerFile — .appinstaller with an auto-update policy for
# HTTPS / file-share sideload distribution.
# New-MsixModificationPackage — a customization package that layers content
# onto a vendor MSIX without modifying it.
# =============================================================================

function New-MsixAppInstallerFile {
    <#
    .SYNOPSIS
        Generates a .appinstaller file (with auto-update policy) for a package,
        for HTTPS or file-share sideload distribution.
 
    .DESCRIPTION
        Reads Name / Publisher / Version / architecture from the package's
        manifest and emits a schema-valid .appinstaller (2018+ schema; the
        update-settings shape used from Windows 10 1903). Users install via
        Add-AppxPackage -AppInstallerFile or by opening the file; Windows then
        checks -Uri for updates per the policy.
 
    .PARAMETER PackagePath
        The .msix the installer file describes (identity is read from it).
 
    .PARAMETER PackageUri
        URL (https://...) or UNC path where the .msix will be hosted.
 
    .PARAMETER AppInstallerUri
        URL/UNC where THIS .appinstaller file will be hosted (used by Windows
        to re-check the file itself). Defaults to the same folder as
        -PackageUri with the .appinstaller name.
 
    .PARAMETER OutputPath
        Where to write the .appinstaller. Default: next to -PackagePath with
        the same base name.
 
    .PARAMETER HoursBetweenUpdateChecks
        Background update-check interval. Default 24. 0 = check on every
        launch.
 
    .PARAMETER OnLaunch
        Check for updates at app launch (adds the OnLaunch element).
 
    .PARAMETER ShowPrompt
        With -OnLaunch: show the user a prompt when an update is found.
 
    .PARAMETER UpdateBlocksActivation
        With -OnLaunch: the app cannot start until the update is applied.
 
    .PARAMETER ForceUpdateFromAnyVersion
        Allow downgrades / same-version reinstalls.
 
    .EXAMPLE
        New-MsixAppInstallerFile -PackagePath .\app.msix `
            -PackageUri 'https://dist.contoso.com/app.msix' -OnLaunch -ShowPrompt
 
    .EXAMPLE
        # File-share distribution, forced hourly checks
        New-MsixAppInstallerFile -PackagePath .\app.msix `
            -PackageUri '\\fs01\apps\app.msix' -HoursBetweenUpdateChecks 1
 
    .OUTPUTS
        [pscustomobject] with Path, PackageUri, AppInstallerUri, Version.
 
    .LINK
        https://learn.microsoft.com/windows/msix/app-installer/app-installer-file-overview
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$PackagePath,
        [Parameter(Mandatory)] [string]$PackageUri,
        [string]$AppInstallerUri,
        [string]$OutputPath,
        [ValidateRange(0, 8760)]
        [int]$HoursBetweenUpdateChecks = 24,
        [switch]$OnLaunch,
        [switch]$ShowPrompt,
        [switch]$UpdateBlocksActivation,
        [switch]$ForceUpdateFromAnyVersion
    )

    if (($ShowPrompt -or $UpdateBlocksActivation) -and -not $OnLaunch) {
        throw '-ShowPrompt / -UpdateBlocksActivation require -OnLaunch.'
    }

    [xml]$manifest = Get-MsixManifest -Path $PackagePath
    $identity  = $manifest.Package.Identity
    $name      = $identity.Name
    $publisher = $identity.Publisher
    $version   = $identity.Version
    $arch      = $identity.GetAttribute('ProcessorArchitecture')
    if (-not $arch) { $arch = 'neutral' }

    if (-not $OutputPath) {
        $OutputPath = [IO.Path]::ChangeExtension((Resolve-Path -LiteralPath $PackagePath).Path, '.appinstaller')
    }
    if (-not $AppInstallerUri) {
        $slash = if ($PackageUri -match '/') { '/' } else { '\' }
        $parent = $PackageUri.Substring(0, $PackageUri.LastIndexOf($slash))
        $AppInstallerUri = $parent + $slash + [IO.Path]::GetFileName($OutputPath)
    }

    if (-not $PSCmdlet.ShouldProcess($OutputPath, 'Write .appinstaller')) { return }

    $ns  = 'http://schemas.microsoft.com/appx/appinstaller/2018'
    $doc = New-Object System.Xml.XmlDocument
    $null = $doc.AppendChild($doc.CreateXmlDeclaration('1.0', 'utf-8', $null))

    $root = $doc.CreateElement('AppInstaller', $ns)
    $root.SetAttribute('Uri', $AppInstallerUri)
    $root.SetAttribute('Version', $version)
    $null = $doc.AppendChild($root)

    $main = $doc.CreateElement('MainPackage', $ns)
    $main.SetAttribute('Name',      $name)
    $main.SetAttribute('Publisher', $publisher)
    $main.SetAttribute('Version',   $version)
    if ($arch -ne 'neutral') { $main.SetAttribute('ProcessorArchitecture', $arch) }
    $main.SetAttribute('Uri', $PackageUri)
    $null = $root.AppendChild($main)

    $settings = $doc.CreateElement('UpdateSettings', $ns)
    if ($OnLaunch) {
        $launch = $doc.CreateElement('OnLaunch', $ns)
        $launch.SetAttribute('HoursBetweenUpdateChecks', [string]$HoursBetweenUpdateChecks)
        if ($ShowPrompt)             { $launch.SetAttribute('ShowPrompt', 'true') }
        if ($UpdateBlocksActivation) { $launch.SetAttribute('UpdateBlocksActivation', 'true') }
        $null = $settings.AppendChild($launch)
    } else {
        $auto = $doc.CreateElement('AutomaticBackgroundTask', $ns)
        $null = $settings.AppendChild($auto)
    }
    if ($ForceUpdateFromAnyVersion) {
        $force = $doc.CreateElement('ForceUpdateFromAnyVersion', $ns)
        $force.InnerText = 'true'
        $null = $settings.AppendChild($force)
    }
    $null = $root.AppendChild($settings)

    $doc.Save($OutputPath)
    Write-MsixLog -Level Info -Message ".appinstaller written: $OutputPath (package $name $version @ $PackageUri)"

    [pscustomobject]@{
        Path            = $OutputPath
        PackageUri      = $PackageUri
        AppInstallerUri = $AppInstallerUri
        Version         = $version
    }
}


function New-MsixModificationPackage {
    <#
    .SYNOPSIS
        Creates a modification package: customization content (settings,
        plugins, license files) layered onto a vendor MSIX without touching it.
 
    .DESCRIPTION
        Builds a package whose manifest declares
        uap4:MainPackageDependency on the target app and carries NO
        Application element - the enterprise-standard way to customize a
        vendor package. At runtime Windows merges the modification package's
        VFS content into the main app's container view.
 
        Identity fields for the modification package itself default to
        '<MainName>.Modification' with the MAIN package's Publisher (required:
        a modification package must be signed with a publisher the main app
        trusts - same publisher, or one allowed by policy).
 
    .PARAMETER MainPackagePath
        The vendor .msix being customized (Name/Publisher are read from it).
 
    .PARAMETER ContentPath
        Folder whose CONTENT becomes the modification package payload.
        Layout it like a package root (e.g. VFS\ProgramFilesX64\App\plugins\...).
 
    .PARAMETER Name
        Identity Name for the modification package.
        Default: '<MainName>.Modification'.
 
    .PARAMETER Version
        Version for the modification package. Default 1.0.0.0.
 
    .PARAMETER DisplayName
        Display name. Default: '<MainName> customization'.
 
    .PARAMETER OutputPath
        Where to write the modification .msix.
        Default: <ContentPath>\..\<Name>.msix.
 
    .PARAMETER SkipSigning
        Skip signing. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        # Layer a plugins folder onto a vendor package
        New-MsixModificationPackage -MainPackagePath .\vendor.msix `
            -ContentPath .\customization `
            -Pfx cert.pfx -PfxPassword $pw
 
    .OUTPUTS
        [pscustomobject] with PackagePath, Name, MainPackageName.
 
    .LINK
        https://learn.microsoft.com/windows/msix/modification-package-insights
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$MainPackagePath,
        [Parameter(Mandatory)] [string]$ContentPath,
        [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]{2,49}$')]
        [string]$Name,
        [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')]
        [string]$Version = '1.0.0.0',
        [string]$DisplayName,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not (Test-Path -LiteralPath $ContentPath -PathType Container)) {
        throw "ContentPath not found or not a directory: $ContentPath"
    }

    [xml]$mainManifest = Get-MsixManifest -Path $MainPackagePath
    $mainId   = $mainManifest.Package.Identity
    $mainName = $mainId.Name
    $publisher = $mainId.Publisher
    $mainPubDisplay = $mainManifest.Package.Properties.PublisherDisplayName
    if (-not $mainPubDisplay) { $mainPubDisplay = 'Unknown publisher' }

    if (-not $Name)        { $Name = "$mainName.Modification" }
    if (-not $DisplayName) { $DisplayName = "$mainName customization" }
    if (-not $OutputPath) {
        $parent = Split-Path -Path (Resolve-Path -LiteralPath $ContentPath).Path -Parent
        $OutputPath = Join-Path -Path $parent -ChildPath "$Name.msix"
    }

    if (-not $PSCmdlet.ShouldProcess($OutputPath, "Create modification package for $mainName")) { return }

    $toolsRoot = Get-MsixToolsRoot
    $staging = Join-Path -Path $env:TEMP -ChildPath ("msix-modpkg-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
    New-Item -ItemType Directory -Path $staging -Force | Out-Null
    try {
        Copy-Item -Path (Join-Path -Path $ContentPath -ChildPath '*') -Destination $staging -Recurse -Force

        $safeName = [Security.SecurityElement]::Escape($Name)
        $safeMainName = [Security.SecurityElement]::Escape($mainName)
        $safePublisher = [Security.SecurityElement]::Escape($publisher)
        $safeDisplayName = [Security.SecurityElement]::Escape($DisplayName)
        $safePublisherDisplayName = [Security.SecurityElement]::Escape($mainPubDisplay)

        $manifestXml = @"
<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
         xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
         xmlns:uap4="http://schemas.microsoft.com/appx/manifest/uap/windows10/4"
         xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
         xmlns:build="http://schemas.microsoft.com/developer/appx/2015/build"
         IgnorableNamespaces="uap uap4 rescap build">
  <Identity Name="$safeName" Publisher="$safePublisher" Version="$Version" ProcessorArchitecture="neutral"/>
  <Properties>
    <DisplayName>$safeDisplayName</DisplayName>
    <PublisherDisplayName>$safePublisherDisplayName</PublisherDisplayName>
    <Logo>Assets\StoreLogo.png</Logo>
  </Properties>
  <Resources>
    <Resource Language="en-us"/>
  </Resources>
  <Dependencies>
    <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0"/>
    <uap4:MainPackageDependency Name="$safeMainName"/>
  </Dependencies>
</Package>
"@

        # Modification packages need a logo asset for the Properties/Logo.
        $assets = Join-Path -Path $staging -ChildPath 'Assets'
        if (-not (Test-Path -LiteralPath (Join-Path -Path $assets -ChildPath 'StoreLogo.png'))) {
            New-Item -ItemType Directory -Path $assets -Force | Out-Null
            # 1x1 transparent PNG.
            $png = [Convert]::FromBase64String('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
            [IO.File]::WriteAllBytes((Join-Path -Path $assets -ChildPath 'StoreLogo.png'), $png)
        }

        $manifestPath = Join-Path -Path $staging -ChildPath 'AppxManifest.xml'
        [IO.File]::WriteAllText($manifestPath, $manifestXml, [Text.UTF8Encoding]::new($false))

        $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $OutputPath, '/d', $staging, '/o')
        Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx pack (modification package)'

        if (-not $SkipSigning) {
            if ($Pfx) { Invoke-MsixSigning -PackagePath $OutputPath -Pfx $Pfx -PfxPassword $PfxPassword }
            else      { Invoke-MsixSigning -PackagePath $OutputPath }
        }
        Write-MsixLog -Level Info -Message "Modification package created: $OutputPath (customizes $mainName)"

        [pscustomobject]@{
            PackagePath     = $OutputPath
            Name            = $Name
            MainPackageName = $mainName
        }
    } finally {
        if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue }
    }
}