MSIX.Core.ps1
|
# Resolved once per module load; overridable via $env:MSIX_TOOLS_PATH $script:ToolsRoot = $null function Get-MsixToolsRoot { <# .SYNOPSIS Returns a folder that contains Tools\MakeAppx.exe. .DESCRIPTION Search order (first hit wins, result cached for the session): 1. $env:MSIX_TOOLS_PATH explicit override 2. <module folder>\Tools\ installed by Install-MsixSdkTool 3. Sibling / parent-walk e.g. ..\0.56\Tools\ 4. Windows 10/11 SDK %ProgramFiles(x86)%\Windows Kits\10\bin 5. Auto-install (if -AutoInstall) one-call download from NuGet .PARAMETER AutoInstall If set and nothing was found, run Install-MsixSdkTool to fetch Microsoft.Windows.SDK.BuildTools and use that. .PARAMETER Refresh Drop the cached result and re-resolve from scratch. .OUTPUTS [string] Absolute path that contains a Tools\MakeAppx.exe. .EXAMPLE # First call resolves and caches; later calls are O(1) $root = Get-MsixToolsRoot & "$root\Tools\MakeAppx.exe" /? .EXAMPLE # Force a one-shot install if nothing is found Get-MsixToolsRoot -AutoInstall .EXAMPLE # Pin a specific layout via env var (overrides every other source) $env:MSIX_TOOLS_PATH = 'C:\tools\msix-sdk' Get-MsixToolsRoot -Refresh #> [CmdletBinding()] [OutputType([string])] param( [switch]$AutoInstall, [switch]$Refresh ) if ($Refresh) { $script:ToolsRoot = $null } if ($script:ToolsRoot) { return $script:ToolsRoot } # 1) Explicit env override if ($env:MSIX_TOOLS_PATH -and (Test-Path "$env:MSIX_TOOLS_PATH\Tools\MakeAppx.exe")) { $script:ToolsRoot = $env:MSIX_TOOLS_PATH return $script:ToolsRoot } # 2) Tools folder next to this module file (Install-MsixSdkTool default) if (Test-Path "$PSScriptRoot\Tools\MakeAppx.exe") { $script:ToolsRoot = $PSScriptRoot return $script:ToolsRoot } # 3) Walk up to four parent levels looking for any sibling that hosts # Tools\MakeAppx.exe (e.g. C:\temp\msix\0.56\ next to C:\temp\msix\MSIX\, # or any other vendored toolchain elsewhere on the same path). $cursor = $PSScriptRoot for ($i = 0; $i -lt 4; $i++) { $cursor = Split-Path $cursor -Parent if (-not $cursor) { break } # Same-level siblings under this ancestor $sibling = Get-ChildItem $cursor -Directory -ErrorAction SilentlyContinue | Where-Object { Test-Path "$($_.FullName)\Tools\MakeAppx.exe" } | Sort-Object Name -Descending | Select-Object -First 1 if ($sibling) { $script:ToolsRoot = $sibling.FullName return $script:ToolsRoot } # Or the ancestor itself if (Test-Path "$cursor\Tools\MakeAppx.exe") { $script:ToolsRoot = $cursor return $script:ToolsRoot } } # 4) Windows SDK default paths — pick the highest-versioned bin dir foreach ($arch in @('x64','x86')) { $kitBin = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" if (Test-Path $kitBin) { # Versioned subfolders + a flat <arch> root (older SDKs) $candidate = Get-ChildItem $kitBin -Directory -ErrorAction SilentlyContinue | Where-Object { Test-Path "$($_.FullName)\$arch\makeappx.exe" } | Sort-Object Name -Descending | Select-Object -First 1 if ($candidate) { $script:ToolsRoot = "$($candidate.FullName)\$arch" return $script:ToolsRoot } if (Test-Path "$kitBin\$arch\makeappx.exe") { $script:ToolsRoot = "$kitBin\$arch" return $script:ToolsRoot } } } # 5) One-shot auto-install if ($AutoInstall) { if (-not (Get-Command Install-MsixSdkTool -ErrorAction SilentlyContinue)) { throw 'Install-MsixSdkTool is not available; cannot auto-install. Make sure the module loaded fully.' } Write-MsixLog Info 'No SDK tools found; auto-installing via Install-MsixSdkTool.' Install-MsixSdkTool | Out-Null if (Test-Path "$PSScriptRoot\Tools\MakeAppx.exe") { $script:ToolsRoot = $PSScriptRoot return $script:ToolsRoot } } throw @" MakeAppx.exe not found. Pick ONE of these: # Easiest -- auto-download MakeAppx + signtool from the official Microsoft # NuGet package (Microsoft.Windows.SDK.BuildTools), once per machine: Install-MsixSdkTool # Or do everything (PSF + Procmon + msixmgr + SDK tools) in a single call: Initialize-MsixToolchain # Or point at an existing layout (must contain Tools\MakeAppx.exe): `$env:MSIX_TOOLS_PATH = 'C:\path\to\toolsroot' Set-MsixToolsRoot -Path 'C:\path\to\toolsroot' "@ } function Set-MsixToolsRoot { <# .SYNOPSIS Pins the tools root used by every cmdlet in this session. .DESCRIPTION Validates that <Path>\Tools\MakeAppx.exe exists, then sets the session-level cache that Get-MsixToolsRoot returns. Use this when you have a vendored SDK layout and don't want to set $env:MSIX_TOOLS_PATH globally. Equivalent to setting $env:MSIX_TOOLS_PATH and then calling Get-MsixToolsRoot -Refresh, but scoped to the current session only. .PARAMETER Path Folder that directly contains a Tools subfolder with MakeAppx.exe. .EXAMPLE Set-MsixToolsRoot -Path 'C:\tools\msix-sdk' # Get-MsixToolsRoot now returns 'C:\tools\msix-sdk'. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [string]$Path ) if (-not (Test-Path "$Path\Tools\MakeAppx.exe")) { throw "MakeAppx.exe not found under '$Path\Tools\'. Verify the path." } $script:ToolsRoot = $Path Write-MsixLog Info "Tools root set to: $Path" } function New-MsixWorkspace { <# .SYNOPSIS Creates a fresh, GUID-stamped temp folder for an unpack/repack cycle. .DESCRIPTION Primarily used internally by Invoke-MsixPipeline, Add-MsixPsfV2, and the context-menu cmdlets to keep multiple concurrent runs isolated. Exposed for callers who script custom unpack/edit/repack flows outside the high-level pipeline. The caller is responsible for removing the workspace when done (Remove-Item -Recurse -Force). .PARAMETER PackageName Short label baked into the folder name. Use the package base name to make the workspace easy to identify while it exists. .OUTPUTS [string] Absolute path of the new directory. .EXAMPLE $ws = New-MsixWorkspace -PackageName 'Contoso.App' try { # unpack, edit, repack into $ws } finally { Remove-Item $ws -Recurse -Force } #> [OutputType([string])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [string]$PackageName ) $id = [guid]::NewGuid().ToString('N').Substring(0, 8) $path = Join-Path $env:TEMP "msix-$PackageName-$id" New-Item -ItemType Directory -Path $path -Force | Out-Null Write-MsixLog Debug "Workspace created: $path" return $path } function Invoke-MsixProcess { <# .SYNOPSIS Runs an external executable and captures its exit code, stdout, and stderr. .DESCRIPTION Arguments are passed as an array (one element per argument) so each argument is correctly quoted by the .NET process API. This prevents argument injection from filenames or values that contain spaces, quotes, or shell metacharacters. .PARAMETER FilePath Absolute path to the executable. .PARAMETER ArgumentList Array of arguments. Each element is one argument; do not pre-concatenate. Example: @('unpack', '/p', $path, '/d', $workspace, '/o') .PARAMETER Arguments DEPRECATED. Legacy single-string argument form. Internally split with a naive parser for backward compatibility -- new callers MUST use -ArgumentList. Logs a warning to encourage migration. .OUTPUTS [pscustomobject] with ExitCode (int), StdOut (string), StdErr (string). .EXAMPLE # Preferred: array form (each argument quoted correctly) Invoke-MsixProcess "$root\Tools\MakeAppx.exe" -ArgumentList @( 'unpack', '/p', $packagePath, '/d', $workspace, '/o' ) .EXAMPLE # DEPRECATED legacy single-string form — emits a warning. New callers # MUST use -ArgumentList; this is retained only for older scripts. Invoke-MsixProcess "$root\Tools\MakeAppx.exe" ` -Arguments "unpack /p `"$packagePath`" /d `"$workspace`" /o" #> [CmdletBinding(DefaultParameterSetName = 'ArgumentList')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, Position = 0)] [string]$FilePath, [Parameter(Mandatory, ParameterSetName = 'ArgumentList', Position = 1)] [AllowEmptyCollection()] [string[]]$ArgumentList, [Parameter(Mandatory, ParameterSetName = 'LegacyString', Position = 1)] [string]$Arguments ) if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { throw "Executable not found: $FilePath" } # Backward-compat: split the legacy single string into an array using a # quote-aware tokenizer. Issues a deprecation warning so callers migrate. if ($PSCmdlet.ParameterSetName -eq 'LegacyString') { Write-MsixLog Warning "Invoke-MsixProcess: -Arguments (single string) is deprecated. Pass -ArgumentList @(...) instead. Caller: $((Get-PSCallStack)[1].Command)" $ArgumentList = @() if ($Arguments) { # Honour double-quoted segments containing spaces; otherwise split on whitespace. $regex = [regex]'(?<=^|\s)"([^"]*)"(?=\s|$)|\S+' foreach ($m in $regex.Matches($Arguments)) { $ArgumentList += if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Value } } } } Write-MsixLog Debug "Exec: $FilePath $([string]::Join(' ', ($ArgumentList | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } })))" $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $FilePath $psi.RedirectStandardError = $true $psi.RedirectStandardOutput = $true $psi.UseShellExecute = $false $psi.WorkingDirectory = (Get-Location).Path # PowerShell 5.1 / .NET Framework 4.x does not expose ProcessStartInfo.ArgumentList. # Fall back to safely quoting into the single Arguments string. The quoting rules # match CommandLineToArgvW: wrap in double quotes; escape embedded " as \" ; double # trailing backslashes before closing quote. if ($null -ne $psi.PSObject.Properties['ArgumentList']) { foreach ($a in $ArgumentList) { [void]$psi.ArgumentList.Add([string]$a) } } else { $psi.Arguments = [string]::Join(' ', ($ArgumentList | ForEach-Object { $s = [string]$_ if ($s -eq '') { return '""' } if ($s -notmatch '[\s"]') { return $s } # Escape embedded backslashes-before-quotes per CommandLineToArgvW rules. $escaped = $s -replace '(\\*)"', '$1$1\"' $escaped = $escaped -replace '(\\+)$', '$1$1' return '"' + $escaped + '"' })) } $p = New-Object System.Diagnostics.Process $p.StartInfo = $psi try { $null = $p.Start() # Read both streams concurrently to prevent buffer deadlocks $stdoutTask = $p.StandardOutput.ReadToEndAsync() $stderrTask = $p.StandardError.ReadToEndAsync() $p.WaitForExit() return [pscustomobject]@{ ExitCode = $p.ExitCode StdOut = $stdoutTask.Result StdErr = $stderrTask.Result } } finally { $p.Dispose() } } function Get-MsixPublisherId { <# .SYNOPSIS Computes the Crockford-Base32-encoded SHA-256 publisher hash used by MSIX for VFS paths and package family names. .DESCRIPTION Implements the algorithm Windows uses to derive PublisherId from a certificate Subject (e.g. 'CN=Contoso, O=Contoso, C=NL'): 1. Encode Publisher as UTF-16LE. 2. SHA-256 the bytes; keep the first 8 bytes. 3. Re-encode those 8 bytes as 13 Crockford-Base32 characters. Useful for predicting the install path under %ProgramFiles%\WindowsApps\<Name>_<Version>_<Arch>__<PublisherId> without having to install the package first. Available under the legacy alias Get-PublisherIdFromPublisher. .PARAMETER Publisher Full publisher Distinguished Name exactly as it appears in AppxManifest.xml's Identity/Publisher attribute. Matching is case-sensitive — even a space difference yields a different ID. .OUTPUTS [string] 13-character lowercase publisher ID. .EXAMPLE Get-MsixPublisherId -Publisher 'CN=Contoso, O=Contoso, C=NL' # -> e.g. 8wekyb3d8bbwe-style id #> [OutputType([string])] param( [Parameter(Mandatory)] [string]$Publisher ) $encUtf16 = [System.Text.Encoding]::Unicode $encSha256 = [System.Security.Cryptography.HashAlgorithm]::Create('SHA256') $bytes = @() ($encSha256.ComputeHash($encUtf16.GetBytes($Publisher)))[0..7] | ForEach-Object { $bytes += '{0:x2}' -f $_ } $bin = (-join $bytes.ForEach{ [convert]::ToString([convert]::ToByte($_, 16), 2).PadLeft(8, '0') }).PadRight(65, '0') $table = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' $coded = '' for ($i = 0; $i -lt $bin.Length; $i += 5) { $coded += $table[[convert]::ToInt32($bin.Substring($i, 5), 2)] } return $coded.ToLower() } Set-Alias -Name Get-PublisherIdFromPublisher -Value Get-MsixPublisherId |