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,
        # HKLM\... / HKCU\... key paths -> @{ ValueName = value } hashtables.
        # Built into Registry.dat / User.dat via the offline-registry helpers so
        # the modification package layers SETTINGS, not just files (issue #131).
        # Strings become REG_SZ, integers REG_DWORD.
        [hashtable]$RegistryContent,
        [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

        if ($RegistryContent -and $RegistryContent.Count -gt 0) {
            _MsixBuildRegistryContent -Staging $staging -RegistryContent $RegistryContent
        }

        $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 }
    }
}

#region .msixbundle handling (issue #125) ------------------------------------

function Expand-MsixBundle {
    <#
    .SYNOPSIS
        Unbundles a .msixbundle into its inner .msix packages.
 
    .PARAMETER BundlePath
        The .msixbundle file.
 
    .PARAMETER OutputFolder
        Folder to unbundle into. Created if missing. Default: a new workspace.
 
    .EXAMPLE
        Expand-MsixBundle -BundlePath app.msixbundle -OutputFolder C:\work\inner
 
    .OUTPUTS
        [pscustomobject] with OutputFolder and Packages (FileInfo[] of the
        inner .msix files, excluding the bundle metadata).
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$BundlePath,
        [string]$OutputFolder
    )

    if (-not (Test-Path -LiteralPath $BundlePath -PathType Leaf)) { throw "Bundle not found: $BundlePath" }
    if (-not $OutputFolder) {
        $OutputFolder = New-MsixWorkspace -PackageName ([IO.Path]::GetFileNameWithoutExtension($BundlePath) + '-unbundle')
    }
    if (-not $PSCmdlet.ShouldProcess($BundlePath, "Unbundle to $OutputFolder")) { return }

    $toolsRoot = Get-MsixToolsRoot
    $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unbundle', '/p', $BundlePath, '/d', $OutputFolder, '/o')
    Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx unbundle'

    $inner = @(Get-ChildItem -LiteralPath $OutputFolder -File |
        Where-Object { $_.Extension -in '.msix', '.appx' })
    Write-MsixLog -Level Info -Message "Unbundled $($inner.Count) inner package(s) from $([IO.Path]::GetFileName($BundlePath))."

    [pscustomobject]@{
        OutputFolder = $OutputFolder
        Packages     = $inner
    }
}


function New-MsixBundle {
    <#
    .SYNOPSIS
        Bundles a folder of .msix packages into a signed .msixbundle.
 
    .PARAMETER SourceFolder
        Folder containing the .msix files to bundle (same Identity Name and
        Version across packages; architectures/languages may differ).
 
    .PARAMETER OutputPath
        The .msixbundle to write.
 
    .PARAMETER BundleVersion
        Optional explicit bundle version (defaults to MakeAppx's derivation
        from the current timestamp when omitted).
 
    .PARAMETER SkipSigning
        Skip the signing pass. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        New-MsixBundle -SourceFolder C:\work\inner -OutputPath app.msixbundle `
            -Pfx cert.pfx -PfxPassword $pw
 
    .OUTPUTS
        [pscustomobject] with BundlePath and PackageCount.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$SourceFolder,
        [Parameter(Mandatory)] [string]$OutputPath,
        [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')]
        [string]$BundleVersion,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not (Test-Path -LiteralPath $SourceFolder -PathType Container)) { throw "SourceFolder not found: $SourceFolder" }
    $inner = @(Get-ChildItem -LiteralPath $SourceFolder -File | Where-Object { $_.Extension -in '.msix', '.appx' })
    if (-not $inner) { throw "No .msix/.appx packages found in $SourceFolder." }
    if (-not $PSCmdlet.ShouldProcess($OutputPath, "Bundle $($inner.Count) package(s)")) { return }

    $toolsRoot = Get-MsixToolsRoot
    $bundleArgs = @('bundle', '/d', $SourceFolder, '/p', $OutputPath, '/o')
    if ($BundleVersion) { $bundleArgs += @('/bv', $BundleVersion) }
    $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList $bundleArgs
    Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx bundle'

    if (-not $SkipSigning) {
        if ($Pfx) { Invoke-MsixSigning -PackagePath $OutputPath -Pfx $Pfx -PfxPassword $PfxPassword }
        else      { Invoke-MsixSigning -PackagePath $OutputPath }
    }
    Write-MsixLog -Level Info -Message "Bundle created: $OutputPath ($($inner.Count) inner package(s))."

    [pscustomobject]@{
        BundlePath   = $OutputPath
        PackageCount = $inner.Count
    }
}


function Invoke-MsixBundleOperation {
    <#
    .SYNOPSIS
        Runs any package mutation against every inner .msix of a .msixbundle,
        then rebundles and signs once - bundle support for ALL mutators
        without per-cmdlet changes.
 
    .DESCRIPTION
        Unbundles, invokes -Operation once per inner package (the scriptblock
        receives the inner .msix path; call any module mutator with
        -SkipSigning inside), removes the stale bundle metadata, rebundles
        with the SAME bundle version (inner package contents changed but
        identities did not), and signs the result.
 
        The bundle signature and every inner package signature are
        invalidated by mutation, so the operation signs the inner packages
        and the bundle in the final pass unless -SkipSigning.
 
    .PARAMETER BundlePath
        The .msixbundle to operate on.
 
    .PARAMETER Operation
        Scriptblock receiving each inner package path. Use -SkipSigning on
        mutators inside; signing happens once at the end.
 
    .PARAMETER Architecture
        Restrict the operation to inner packages of this architecture
        (read from each inner manifest's Identity). Others pass through
        unchanged. Default: all.
 
    .PARAMETER OutputPath
        Write the rebuilt bundle here instead of overwriting -BundlePath.
 
    .PARAMETER SkipSigning
        Skip signing of inner packages and the bundle. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        # Add a capability to every inner package of a bundle
        Invoke-MsixBundleOperation -BundlePath app.msixbundle -Operation {
            param($pkg)
            Add-MsixCapability -PackagePath $pkg -Names runFullTrust -SkipSigning
        } -Pfx cert.pfx -PfxPassword $pw
 
    .EXAMPLE
        # Isolate only the x64 flavour
        Invoke-MsixBundleOperation -BundlePath app.msixbundle -Architecture x64 -Operation {
            param($pkg)
            Add-MsixAppIsolation -PackagePath $pkg -SkipSigning
        } -SkipSigning
 
    .OUTPUTS
        [pscustomobject] with BundlePath, PackagesMutated, PackagesTotal.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$BundlePath,
        [Parameter(Mandatory)] [scriptblock]$Operation,
        [ValidateSet('x64', 'x86', 'arm64', 'neutral')]
        [string]$Architecture,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not $PSCmdlet.ShouldProcess($BundlePath, 'Mutate bundle contents')) { return }

    $target = if ($OutputPath) { $OutputPath } else { $BundlePath }
    $work = $null
    try {
        $expanded = Expand-MsixBundle -BundlePath $BundlePath
        $work = $expanded.OutputFolder

        # Read the current bundle version so the rebuilt bundle keeps it.
        $bundleVersion = $null
        $bmPath = Join-Path -Path $work -ChildPath 'AppxMetadata\AppxBundleManifest.xml'
        if (Test-Path -LiteralPath $bmPath) {
            [xml]$bm = Get-MsixManifest -Path $bmPath
            $bundleVersion = $bm.Bundle.Identity.Version
            # MakeAppx bundle refuses a folder still carrying old metadata.
            $meta = Join-Path -Path $work -ChildPath 'AppxMetadata'
            if (Test-Path -LiteralPath $meta) { [IO.Directory]::Delete($meta, $true) }
        }

        $mutated = 0
        foreach ($pkg in $expanded.Packages) {
            $skip = $false
            if ($Architecture) {
                [xml]$inner = Get-MsixManifest -Path $pkg.FullName
                $arch = $inner.Package.Identity.GetAttribute('ProcessorArchitecture')
                if (-not $arch) { $arch = 'neutral' }
                $skip = ($arch -ne $Architecture)
            }
            if ($skip) { continue }
            Write-MsixLog -Level Info -Message "Bundle operation -> $($pkg.Name)"
            & $Operation $pkg.FullName
            if (-not $SkipSigning) {
                if ($Pfx) { Invoke-MsixSigning -PackagePath $pkg.FullName -Pfx $Pfx -PfxPassword $PfxPassword }
                else      { Invoke-MsixSigning -PackagePath $pkg.FullName }
            }
            $mutated++
        }

        $bundleArgs = @{
            SourceFolder = $work
            OutputPath   = $target
            SkipSigning  = $SkipSigning
        }
        if ($bundleVersion) { $bundleArgs['BundleVersion'] = $bundleVersion }
        if ($Pfx)           { $bundleArgs['Pfx'] = $Pfx; $bundleArgs['PfxPassword'] = $PfxPassword }
        $null = New-MsixBundle @bundleArgs

        Write-MsixLog -Level Info -Message "Bundle operation complete: $mutated/$($expanded.Packages.Count) inner package(s) mutated."
        [pscustomobject]@{
            BundlePath      = $target
            PackagesMutated = $mutated
            PackagesTotal   = $expanded.Packages.Count
        }
    } finally {
        if ($work -and (Test-Path -LiteralPath $work)) { [IO.Directory]::Delete($work, $true) }
    }
}

#endregion


#region Shared runtime framework packages (issue #130) -----------------------

function New-MsixFrameworkPackage {
    <#
    .SYNOPSIS
        Builds a FRAMEWORK package from a runtime folder (JRE, .NET runtime,
        Python, in-house shared libraries) so many app packages can depend on
        one servicing point instead of each bundling its own copy.
 
    .DESCRIPTION
        Emits a package whose manifest declares <Framework>true</Framework>
        and carries NO Application element. Consumer apps reference it with
        Add-MsixRuntimeDependency (which declares the <PackageDependency> and
        optionally wires environment variables such as JAVA_HOME).
 
        Servicing story: patch the runtime by publishing a new framework
        version; consumer packages declare MinVersion and pick it up without
        being rebuilt. For DLL-based runtimes no wiring is needed at all -
        a packaged process's DLL search path includes its dependency
        packages' roots (the package graph).
 
    .PARAMETER RuntimeFolder
        Folder whose CONTENT becomes the framework payload (e.g. the root of
        an extracted JRE - bin\, lib\, ...).
 
    .PARAMETER Name
        Identity Name for the framework (e.g. Contoso.Java.17).
 
    .PARAMETER Version
        Framework version (e.g. 17.0.11.0).
 
    .PARAMETER Publisher
        Publisher DN the framework will be signed with (must match the
        signing certificate, and consumers must declare the same string).
 
    .PARAMETER DisplayName
        Display name. Default: the Name.
 
    .PARAMETER Architecture
        x86 | x64 | arm64 | neutral. Default x64 (runtimes are usually
        architecture-specific).
 
    .PARAMETER OutputPath
        The framework .msix to write. Default: <Name>.msix next to
        -RuntimeFolder.
 
    .PARAMETER SkipSigning
        Skip signing. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        # Package a JRE once...
        New-MsixFrameworkPackage -RuntimeFolder C:\runtimes\jre-17 `
            -Name 'Contoso.Java.17' -Version 17.0.11.0 `
            -Publisher 'CN=Contoso Ltd' -Pfx cert.pfx -PfxPassword $pw
 
        # ...then wire any number of apps to it:
        Add-MsixRuntimeDependency -PackagePath app.msix `
            -FrameworkName 'Contoso.Java.17' -FrameworkMinVersion 17.0.11.0 `
            -FrameworkPublisher 'CN=Contoso Ltd' -Runtime Java -SkipSigning
 
    .OUTPUTS
        [pscustomobject] with PackagePath, Name, Version, Architecture.
 
    .LINK
        https://learn.microsoft.com/windows/msix/package/create-app-package-with-makeappx-tool
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$RuntimeFolder,
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]{2,49}$')]
        [string]$Name,
        [Parameter(Mandatory)]
        [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')]
        [string]$Version,
        [Parameter(Mandatory)] [string]$Publisher,
        [string]$DisplayName,
        [ValidateSet('x86', 'x64', 'arm64', 'neutral')]
        [string]$Architecture = 'x64',
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not (Test-Path -LiteralPath $RuntimeFolder -PathType Container)) {
        throw "RuntimeFolder not found or not a directory: $RuntimeFolder"
    }
    if (-not $DisplayName) { $DisplayName = $Name }
    if (-not $OutputPath) {
        $parent = Split-Path -Path (Resolve-Path -LiteralPath $RuntimeFolder).Path -Parent
        $OutputPath = Join-Path -Path $parent -ChildPath "$Name.msix"
    }

    if (-not $PSCmdlet.ShouldProcess($OutputPath, "Create framework package $Name $Version")) { return }

    $toolsRoot = Get-MsixToolsRoot
    $staging = Join-Path -Path $env:TEMP -ChildPath ("msix-fwpkg-{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 $RuntimeFolder -ChildPath '*') -Destination $staging -Recurse -Force

        $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"
         IgnorableNamespaces="uap">
  <Identity Name="$Name" Publisher="$([Security.SecurityElement]::Escape($Publisher))" Version="$Version" ProcessorArchitecture="$Architecture"/>
  <Properties>
    <Framework>true</Framework>
    <DisplayName>$([Security.SecurityElement]::Escape($DisplayName))</DisplayName>
    <PublisherDisplayName>$([Security.SecurityElement]::Escape($Publisher))</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"/>
  </Dependencies>
</Package>
"@

        $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
            $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 (framework package)'

        if (-not $SkipSigning) {
            if ($Pfx) { Invoke-MsixSigning -PackagePath $OutputPath -Pfx $Pfx -PfxPassword $PfxPassword }
            else      { Invoke-MsixSigning -PackagePath $OutputPath }
        }
        Write-MsixLog -Level Info -Message "Framework package created: $OutputPath ($Name $Version, $Architecture)"

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


function Add-MsixRuntimeDependency {
    <#
    .SYNOPSIS
        Wires an app package to a shared runtime framework package: declares
        the PackageDependency and (optionally) environment variables like
        JAVA_HOME pointing into the framework's install location.
 
    .DESCRIPTION
        Two layers, per what the runtime needs:
 
        1. Dependency (always): <PackageDependency Name= MinVersion= Publisher=>
           via Add-MsixPackageDependency. For DLL-based runtimes this is
           usually enough - a packaged process's DLL search includes its
           dependency packages' roots (the package graph).
 
        2. Environment variables (optional, needs PSF): runtimes addressed by
           PATH-style discovery (java.exe via JAVA_HOME, dotnet via
           DOTNET_ROOT) get a PSF EnvVarFixup. The framework's install root is
           computed as
             C:\Program Files\WindowsApps\<Name>_<MinVersion>_<arch>__<publisherId>
           and substituted for the '{frameworkRoot}' token in values.
 
           WARNING: that path pins the EXACT MinVersion - when you service the
           framework to a newer version, re-run this cmdlet (or keep the
           framework version and update its content). The cmdlet warns about
           this at wiring time.
 
    .PARAMETER PackagePath
        The consumer app .msix to modify.
 
    .PARAMETER FrameworkName
        Framework Identity Name (as given to New-MsixFrameworkPackage).
 
    .PARAMETER FrameworkMinVersion
        Minimum framework version.
 
    .PARAMETER FrameworkPublisher
        Framework Publisher DN (must match the framework package identity).
 
    .PARAMETER FrameworkArchitecture
        Architecture used in the WindowsApps folder name. Default x64.
 
    .PARAMETER Runtime
        Preset for common runtimes:
          Java -> JAVA_HOME = {frameworkRoot}
          DotNet -> DOTNET_ROOT = {frameworkRoot}
          None -> no env wiring unless -EnvironmentVariables is given.
 
    .PARAMETER EnvironmentVariables
        Custom env vars. Values may contain the '{frameworkRoot}' token,
        replaced with the computed framework install path.
 
    .PARAMETER OutputPath
        Write the modified package here instead of overwriting -PackagePath.
 
    .PARAMETER SkipSigning
        Skip signing. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        Add-MsixRuntimeDependency -PackagePath app.msix `
            -FrameworkName 'Contoso.Java.17' -FrameworkMinVersion 17.0.11.0 `
            -FrameworkPublisher 'CN=Contoso Ltd' -Runtime Java -SkipSigning
 
    .EXAMPLE
        # DLL-based runtime: dependency only, no env wiring, no PSF
        Add-MsixRuntimeDependency -PackagePath app.msix `
            -FrameworkName 'Contoso.SharedLibs' -FrameworkMinVersion 1.0.0.0 `
            -FrameworkPublisher 'CN=Contoso Ltd' -SkipSigning
 
    .OUTPUTS
        [pscustomobject] with PackagePath, FrameworkName, FrameworkRoot,
        EnvironmentWired.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$PackagePath,
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]{2,49}$')]
        [string]$FrameworkName,
        [Parameter(Mandatory)]
        [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')]
        [string]$FrameworkMinVersion,
        [Parameter(Mandatory)] [string]$FrameworkPublisher,
        [ValidateSet('x86', 'x64', 'arm64', 'neutral')]
        [string]$FrameworkArchitecture = 'x64',
        [ValidateSet('Java', 'DotNet', 'None')]
        [string]$Runtime = 'None',
        [hashtable]$EnvironmentVariables,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not $PSCmdlet.ShouldProcess($PackagePath, "Add runtime dependency on $FrameworkName")) { return }

    # 1) The dependency itself.
    Add-MsixPackageDependency -PackagePath $PackagePath -Name $FrameworkName `
        -MinVersion $FrameworkMinVersion -Publisher $FrameworkPublisher `
        -OutputPath $OutputPath -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword

    $target = if ($OutputPath) { $OutputPath } else { $PackagePath }

    # 2) Optional env wiring via PSF.
    $envVars = @{}
    switch ($Runtime) {
        'Java'   { $envVars['JAVA_HOME']   = '{frameworkRoot}' }
        'DotNet' { $envVars['DOTNET_ROOT'] = '{frameworkRoot}' }
    }
    foreach ($k in @($EnvironmentVariables).Keys) { $envVars[$k] = $EnvironmentVariables[$k] }

    $frameworkRoot = $null
    $wired = $false
    if ($envVars.Count -gt 0) {
        $pubId = Get-MsixPublisherId -Publisher $FrameworkPublisher
        $frameworkRoot = "C:\Program Files\WindowsApps\${FrameworkName}_${FrameworkMinVersion}_${FrameworkArchitecture}__${pubId}"
        Write-MsixLog -Level Warning -Message "Environment wiring pins the framework path to version $FrameworkMinVersion ($frameworkRoot). Re-run Add-MsixRuntimeDependency after servicing the framework to a new version."

        $resolved = @{}
        foreach ($k in $envVars.Keys) {
            $resolved[$k] = ([string]$envVars[$k]).Replace('{frameworkRoot}', $frameworkRoot)
        }
        $fixup = New-MsixPsfEnvVarConfig -Variables $resolved
        Add-MsixPsfV2 -PackagePath $target -Fixups @($fixup) -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword
        $wired = $true
    } else {
        Write-MsixLog -Level Info -Message 'No environment wiring requested; the dependency alone covers DLL-based runtimes (package-graph DLL search).'
    }

    [pscustomobject]@{
        PackagePath       = $target
        FrameworkName     = $FrameworkName
        FrameworkRoot     = $frameworkRoot
        EnvironmentWired  = $wired
    }
}

#endregion


#region Modification-package registry + diff (issue #131) --------------------

function _MsixBuildRegistryContent {
    # Builds Registry.dat (HKLM keys, rooted at REGISTRY\MACHINE) and/or
    # User.dat (HKCU keys, rooted directly) in $Staging from a hashtable of
    # 'HKLM\path' / 'HKCU\path' -> @{ ValueName = value } entries.
    # String values -> REG_SZ; [int]/[uint32] -> REG_DWORD.
    param(
        [Parameter(Mandatory)] [string]$Staging,
        [Parameter(Mandatory)] [hashtable]$RegistryContent
    )

    $machine = [ordered]@{}
    $user    = [ordered]@{}
    foreach ($keyPath in $RegistryContent.Keys) {
        $values = $RegistryContent[$keyPath]
        if ($values -isnot [hashtable]) { throw "RegistryContent['$keyPath'] must be a hashtable of ValueName = value." }
        if ($keyPath -match '^(HKLM|HKEY_LOCAL_MACHINE)\\(.+)$') {
            $machine['REGISTRY\MACHINE\' + $Matches[2]] = $values
        } elseif ($keyPath -match '^(HKCU|HKEY_CURRENT_USER)\\(.+)$') {
            $user[$Matches[2]] = $values
        } else {
            throw "RegistryContent keys must start with HKLM\ or HKCU\ (got '$keyPath')."
        }
    }

    foreach ($set in @(
        @{ Map = $machine; File = 'Registry.dat' },
        @{ Map = $user;    File = 'User.dat' }
    )) {
        if ($set.Map.Count -eq 0) { continue }
        $hive = _MsixCreateOfflineHive
        try {
            foreach ($subKey in $set.Map.Keys) {
                # ORCreateKey does not create intermediate keys (error 2 on a
                # deep path) - walk the segments, closing intermediate handles.
                $segments = $subKey -split '\\'
                $parent = $hive
                $opened = @()
                foreach ($segment in $segments) {
                    $next = _MsixOfflineCreateKey -Parent $parent -SubKey $segment
                    if ($parent -ne $hive) { $opened += $parent }
                    $parent = $next
                }
                $key = $parent
                foreach ($h in $opened) { _MsixOfflineCloseKey -Key $h }
                try {
                    $values = $set.Map[$subKey]
                    foreach ($valName in $values.Keys) {
                        $val = $values[$valName]
                        if ($val -is [int] -or $val -is [uint32] -or $val -is [long]) {
                            _MsixOfflineSetValueDword -Key $key -Name $valName -Value ([uint32]$val)
                        } else {
                            _MsixOfflineSetValueString -Key $key -Name $valName -Value ([string]$val)
                        }
                    }
                } finally {
                    _MsixOfflineCloseKey -Key $key
                }
            }
            $datPath = Join-Path -Path $Staging -ChildPath $set.File
            _MsixOfflineSaveHive -Hive $hive -Path $datPath
            Write-MsixLog -Level Info -Message "$($set.File) built with $($set.Map.Count) key(s)."
        } finally {
            _MsixCloseOfflineHive -Hive $hive
        }
    }
}


function ConvertTo-MsixModificationPackage {
    <#
    .SYNOPSIS
        Generates a modification package from the DIFF between a vendor
        package and a customized copy - "productize the golden-image delta".
 
    .DESCRIPTION
        Unpacks both packages, computes the payload delta (files added or
        changed in the customized copy, compared by SHA-256), stages the delta
        as modification-package content, and calls New-MsixModificationPackage
        against the vendor package's identity.
 
        Registry: when the customized package's Registry.dat / User.dat differ
        from the vendor's, the customized hive files are carried into the
        modification package wholesale (a warning notes they may re-state
        vendor defaults - harmless on overlay, but review for secrets).
 
        Footprint files (AppxManifest.xml, signature, blockmap, metadata,
        resources.pri) are never part of the delta.
 
    .PARAMETER MainPackagePath
        The unmodified vendor .msix.
 
    .PARAMETER CustomizedPackagePath
        The customized copy of the same package (same identity family).
 
    .PARAMETER Name
        Identity Name for the modification package.
        Default: '<MainName>.Modification'.
 
    .PARAMETER Version
        Modification package version. Default 1.0.0.0.
 
    .PARAMETER OutputPath
        Where to write the modification .msix.
 
    .PARAMETER SkipSigning
        Skip signing. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        ConvertTo-MsixModificationPackage `
            -MainPackagePath .\vendor.msix `
            -CustomizedPackagePath .\vendor-customized.msix `
            -OutputPath .\vendor-settings.msix -SkipSigning
 
    .OUTPUTS
        [pscustomobject] with PackagePath, FilesAdded, FilesChanged,
        RegistryCarried.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$MainPackagePath,
        [Parameter(Mandatory)] [string]$CustomizedPackagePath,
        [string]$Name,
        [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')]
        [string]$Version = '1.0.0.0',
        [Parameter(Mandatory)] [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not $PSCmdlet.ShouldProcess($OutputPath, 'Create modification package from diff')) { return }

    $toolsRoot = Get-MsixToolsRoot
    $wsMain = Join-Path -Path $env:TEMP -ChildPath ("msix-diff-a-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
    $wsCust = Join-Path -Path $env:TEMP -ChildPath ("msix-diff-b-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
    $content = Join-Path -Path $env:TEMP -ChildPath ("msix-diff-c-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
    try {
        foreach ($pair in @(@($MainPackagePath, $wsMain), @($CustomizedPackagePath, $wsCust))) {
            $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', (Resolve-Path -LiteralPath $pair[0]).Path, '/d', $pair[1], '/o')
            Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx unpack (diff)'
        }
        New-Item -ItemType Directory -Path $content -Force | Out-Null

        # Normalize long paths (8.3 segments corrupt relative offsets).
        $mainRoot = (Get-Item -LiteralPath $wsMain).FullName
        $custRoot = (Get-Item -LiteralPath $wsCust).FullName

        $footprint = @('AppxManifest.xml', 'AppxBlockMap.xml', 'AppxSignature.p7x', '[Content_Types].xml', 'resources.pri')
        $registryFiles = @('Registry.dat', 'User.dat', 'UserClasses.dat')

        $added = 0; $changed = 0; $registryCarried = @()
        foreach ($file in (Get-ChildItem -LiteralPath $custRoot -Recurse -File)) {
            $rel = $file.FullName.Substring($custRoot.Length + 1)
            $leaf = [IO.Path]::GetFileName($rel)
            if ($rel -like 'AppxMetadata*') { continue }
            if ($leaf -in $footprint -and $rel -eq $leaf) { continue }

            $mainFile = Join-Path -Path $mainRoot -ChildPath $rel
            $isRegistry = ($leaf -in $registryFiles -and $rel -eq $leaf)
            $differs = $true
            if (Test-Path -LiteralPath $mainFile) {
                $hA = (Get-FileHash -LiteralPath $mainFile   -Algorithm SHA256).Hash
                $hB = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
                $differs = ($hA -ne $hB)
                if ($differs -and -not $isRegistry) { $changed++ }
            } elseif (-not $isRegistry) {
                $added++
            }
            if (-not $differs) { continue }

            if ($isRegistry) { $registryCarried += $leaf }
            $destination = Join-Path -Path $content -ChildPath $rel
            $destDir = [IO.Path]::GetDirectoryName($destination)
            if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null }
            Copy-Item -LiteralPath $file.FullName -Destination $destination -Force
        }

        if ($registryCarried) {
            Write-MsixLog -Level Warning -Message ("Customized registry hive(s) carried wholesale into the modification package: {0}. They may re-state vendor defaults (harmless on overlay) - review for machine-specific or sensitive values." -f ($registryCarried -join ', '))
        }
        if ($added -eq 0 -and $changed -eq 0 -and -not $registryCarried) {
            Write-MsixLog -Level Warning -Message 'No differences found between the packages; nothing to productize.'
            return $null
        }
        Write-MsixLog -Level Info -Message "Delta: $added added, $changed changed, registry: $($registryCarried.Count) hive file(s)."

        $modArgs = @{
            MainPackagePath = $MainPackagePath
            ContentPath     = $content
            Version         = $Version
            OutputPath      = $OutputPath
            SkipSigning     = [bool]$SkipSigning
        }
        if ($Name) { $modArgs['Name'] = $Name }
        if ($Pfx)  { $modArgs['Pfx'] = $Pfx; $modArgs['PfxPassword'] = $PfxPassword }
        $result = New-MsixModificationPackage @modArgs

        [pscustomobject]@{
            PackagePath     = $result.PackagePath
            FilesAdded      = $added
            FilesChanged    = $changed
            RegistryCarried = @($registryCarried)
        }
    } finally {
        foreach ($d in @($wsMain, $wsCust, $content)) {
            if (Test-Path -LiteralPath $d) { Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}

#endregion