MSIX.Scripts.ps1
|
# ============================================================================= # Standard scripts (PSADT-flavoured) # ----------------------------------------------------------------------------- # Renders parameterised .ps1 scripts from the templates\ folder, optionally # signs them, and (via Add-MsixStandardScript) wires them into a package as a # PSF startScript. # # Templates use <#PARAM:Name#> placeholders; replacements happen in-memory and # the output goes to the path you choose. # ============================================================================= $script:TemplateDir = Join-Path $PSScriptRoot 'templates' # Catalogue: name -> { Template, Description, RequiredParams } $script:StandardScriptCatalogue = [ordered]@{ CreateShortcut = @{ Template = 'CreateShortcut.ps1.tmpl' Description = 'Create a desktop / start-menu shortcut to the packaged app on first launch.' RequiredParams = @('DisplayName','Target') Defaults = @{ Arguments = '' WorkingDirectory = '' IconPath = '' Location = 'Desktop' } } CopyIconToAppData = @{ Template = 'CopyIconToAppData.ps1.tmpl' Description = 'Copy bundled icon(s) into %APPDATA% so shortcuts survive package updates.' RequiredParams = @('SourceFiles','DestSubfolder') Defaults = @{} } CleanupOldUserData = @{ Template = 'CleanupOldUserData.ps1.tmpl' Description = 'Idempotent removal of legacy user-state directories and registry keys.' RequiredParams = @() Defaults = @{ Paths = '' RegistryKeys = '' OnlyOlderThanDays= '0' } } RegisterFileAssociation = @{ Template = 'RegisterFileAssociation.ps1.tmpl' Description = 'Register a host-side file association under HKCU pointing at an alias.' RequiredParams = @('Extensions','ProgId','Target') Defaults = @{ Arguments = '' } } CustomerSettingsBootstrap = @{ Template = 'CustomerSettingsBootstrap.ps1.tmpl' Description = 'Bake in customer-specific HKCU settings as JSON; written on first launch.' RequiredParams = @('HivePath','Settings') Defaults = @{ Overwrite = 'false' } } } function Get-MsixStandardScript { <# .SYNOPSIS Lists the standard-script templates this module ships with. .DESCRIPTION Returns one entry per template in the module's templates\ folder. The catalogue is used as input to New-MsixStandardScript and Add-MsixStandardScript and lists the placeholder parameters that each template needs (RequiredParams) and the ones it accepts optionally (OptionalParams, with defaults). The module currently ships five PSADT-flavoured templates: CreateShortcut, CopyIconToAppData, CleanupOldUserData, RegisterFileAssociation, CustomerSettingsBootstrap. .OUTPUTS [pscustomobject] one per template, with Name, Description, RequiredParams, OptionalParams, Template (full path on disk). .EXAMPLE Get-MsixStandardScript | Format-Table Name, Description .EXAMPLE # Inspect the placeholders for a specific template Get-MsixStandardScript | Where-Object Name -eq 'CreateShortcut' #> [CmdletBinding()] param() foreach ($k in $script:StandardScriptCatalogue.Keys) { $v = $script:StandardScriptCatalogue[$k] [pscustomobject]@{ Name = $k Description = $v.Description RequiredParams = $v.RequiredParams OptionalParams = @($v.Defaults.Keys) Template = Join-Path $script:TemplateDir $v.Template } } } function _MsixRenderTemplate { param( [string]$TemplatePath, [hashtable]$Parameters ) if (-not (Test-Path $TemplatePath)) { throw "Template not found: $TemplatePath" } $text = Get-Content $TemplatePath -Raw # Find every <#PARAM:Name#> in the template, replace from $Parameters, # complain about anything left unsubstituted. $pattern = '<#PARAM:([A-Za-z0-9_]+)#>' $needed = [regex]::Matches($text, $pattern) | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique foreach ($name in $needed) { if (-not $Parameters.ContainsKey($name)) { throw "Template '$TemplatePath' requires -$name but it was not provided." } $value = [string]$Parameters[$name] # Literal (non-regex) substitution — values may contain backslashes, # quotes, regex metacharacters etc. $text = $text.Replace("<#PARAM:$name#>", $value) } return $text } function New-MsixStandardScript { <# .SYNOPSIS Generates a customised PowerShell script from a bundled template. .DESCRIPTION Picks one of the standard templates, substitutes parameters, writes the result to disk. Optionally signs the resulting .ps1 with a provided code-signing certificate so the package can run it under AllSigned/RemoteSigned execution policies. See Get-MsixStandardScript for the list of templates. .PARAMETER Name Template name (e.g. 'CreateShortcut'). .PARAMETER Parameters Hashtable of placeholder values (e.g. @{ DisplayName='Foo'; Target='foo.exe' }). .PARAMETER OutputPath Where to write the generated .ps1. .PARAMETER Pfx Path to a code-signing PFX. If supplied, the rendered script is signed via Set-MsixScriptSignature. Omit to leave the script unsigned. .PARAMETER PfxPassword SecureString password for the PFX. Required when -Pfx is supplied. .PARAMETER TimestampUrl RFC 3161 timestamp server. Default: http://timestamp.digicert.com. .OUTPUTS [System.IO.FileInfo] for the generated .ps1. .EXAMPLE New-MsixStandardScript -Name CreateShortcut ` -Parameters @{ DisplayName='Contoso Expenses'; Target='contosoexpenses.exe' } ` -OutputPath C:\src\createshortcut.ps1 ` -Pfx cert.pfx -PfxPassword (Read-Host -AsSecureString) .EXAMPLE # Render unsigned, for local testing New-MsixStandardScript -Name CleanupOldUserData ` -Parameters @{ Paths='%AppData%\Contoso\v1'; OnlyOlderThanDays='30' } ` -OutputPath .\cleanup.ps1 #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [ValidateScript({ $_ -in $script:StandardScriptCatalogue.Keys })] [string]$Name, [Parameter(Mandatory)] [hashtable]$Parameters, [Parameter(Mandatory)] [string]$OutputPath, [string]$Pfx, [SecureString]$PfxPassword, [string]$TimestampUrl = 'http://timestamp.digicert.com' ) $entry = $script:StandardScriptCatalogue[$Name] foreach ($req in $entry.RequiredParams) { if (-not $Parameters.ContainsKey($req) -or [string]::IsNullOrEmpty($Parameters[$req])) { throw "Template '$Name' requires -$req." } } # Apply defaults $merged = @{} foreach ($k in $entry.Defaults.Keys) { $merged[$k] = $entry.Defaults[$k] } foreach ($k in $Parameters.Keys) { $merged[$k] = $Parameters[$k] } $tmpl = Join-Path $script:TemplateDir $entry.Template $content = _MsixRenderTemplate -TemplatePath $tmpl -Parameters $merged if ($PSCmdlet.ShouldProcess($OutputPath, "Generate $Name from template")) { $dir = Split-Path $OutputPath -Parent if ($dir -and -not (Test-Path $dir)) { New-Item $dir -ItemType Directory -Force | Out-Null } Set-Content -Path $OutputPath -Value $content -Encoding utf8 Write-MsixLog Info "Generated $Name -> $OutputPath" } if ($Pfx) { Set-MsixScriptSignature -ScriptPath $OutputPath -Pfx $Pfx -PfxPassword $PfxPassword -TimestampUrl $TimestampUrl } return Get-Item $OutputPath } function Set-MsixScriptSignature { <# .SYNOPSIS Signs a PowerShell script (Authenticode + RFC 3161 timestamp) using the same certificate the rest of the module uses for package signing. .DESCRIPTION Wraps Set-AuthenticodeSignature so callers don't have to deal with certificate loading / timestamp server arguments. .PARAMETER ScriptPath .ps1 (or .psm1) file to sign. .PARAMETER Pfx Path to the PFX certificate. Required. .PARAMETER PfxPassword SecureString password for the PFX. Required. .PARAMETER TimestampUrl RFC 3161 server. Default: http://timestamp.digicert.com. .OUTPUTS [System.Management.Automation.Signature] returned by Set-AuthenticodeSignature. .EXAMPLE Set-MsixScriptSignature -ScriptPath .\createshortcut.ps1 ` -Pfx .\cert.pfx -PfxPassword (Read-Host -AsSecureString) #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [string]$ScriptPath, [Parameter(Mandatory)] [string]$Pfx, [Parameter(Mandatory)] [SecureString]$PfxPassword, [string]$TimestampUrl = 'http://timestamp.digicert.com' ) if (-not (Test-Path $ScriptPath)) { throw "Script not found: $ScriptPath" } if (-not (Test-Path $Pfx)) { throw "PFX not found: $Pfx" } $cert = Get-PfxCertificate -FilePath $Pfx -Password $PfxPassword -ErrorAction Stop $sig = Set-AuthenticodeSignature -FilePath $ScriptPath -Certificate $cert ` -TimestampServer $TimestampUrl ` -HashAlgorithm SHA256 -ErrorAction Stop if ($sig.Status -ne 'Valid') { Write-MsixLog Warning "Script signature status: $($sig.Status) ($($sig.StatusMessage))" } else { Write-MsixLog Info "Signed: $ScriptPath ($($sig.SignerCertificate.Thumbprint))" } return $sig } function Add-MsixStandardScript { <# .SYNOPSIS High-level: generate a standard script, sign it, and inject it into an MSIX package as a PSF startScript in one call. .DESCRIPTION Combines New-MsixStandardScript + Add-MsixPsfV2 -AppOptions / -AdditionalFiles. The signing certificate is shared between the script and the package so they form a coherent signed artefact. .PARAMETER PackagePath .msix file to modify. .PARAMETER AppId Application Id to attach the startScript to. .PARAMETER Name Standard-script template name (see Get-MsixStandardScript). .PARAMETER Parameters Hashtable of values to substitute into the template. .PARAMETER ScriptFileName Name of the .ps1 inside the package (default: <Name>.ps1). .PARAMETER RunOnce Forwarded to New-MsixPsfStartScriptConfig. Run the script only on the first launch of the app. .PARAMETER WaitForScriptToFinish Forwarded to New-MsixPsfStartScriptConfig. Block app launch until the startScript exits. .PARAMETER ShowWindow Forwarded to New-MsixPsfStartScriptConfig. Show the script's console window (otherwise hidden). .PARAMETER RunInVirtualEnvironment Forwarded to New-MsixPsfStartScriptConfig. Run the script inside the package's virtual environment. .PARAMETER StopOnScriptError Forwarded to New-MsixPsfStartScriptConfig. Abort app launch if the script returns a non-zero exit code. .PARAMETER Timeout Forwarded to New-MsixPsfStartScriptConfig. Timeout in seconds. 0 = no timeout (default). .PARAMETER EndScript Forwarded to New-MsixPsfStartScriptConfig. Treat the script as an endScript instead of a startScript. .PARAMETER Pfx Path to the code-signing PFX. Used for BOTH the rendered .ps1 and the repacked .msix. .PARAMETER PfxPassword SecureString password for -Pfx. .PARAMETER OutputPath Forwarded to Add-MsixPsfV2. Where to write the repacked .msix. Defaults to overwriting -PackagePath. .PARAMETER SkipSigning Forwarded to Add-MsixPsfV2. Skip signing of both the script and the package. Alias: -NoSign. .OUTPUTS [pscustomobject] returned by Add-MsixPsfV2 (PackagePath, fixups list, etc.). .EXAMPLE Add-MsixStandardScript -PackagePath .\app.msix -AppId 'App' ` -Name CreateShortcut ` -Parameters @{ DisplayName='Contoso'; Target='contoso.exe' } ` -RunOnce -WaitForScriptToFinish ` -Pfx .\cert.pfx -PfxPassword (Read-Host -AsSecureString) #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [string]$AppId, [Parameter(Mandatory)] [ValidateScript({ $_ -in $script:StandardScriptCatalogue.Keys })] [string]$Name, [Parameter(Mandatory)] [hashtable]$Parameters, [string]$ScriptFileName, [switch]$RunOnce, [switch]$WaitForScriptToFinish, [switch]$ShowWindow, [switch]$RunInVirtualEnvironment, [switch]$StopOnScriptError, [int]$Timeout = 0, [switch]$EndScript, [string]$Pfx, [SecureString]$PfxPassword, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning ) if (-not $ScriptFileName) { $ScriptFileName = "$Name.ps1" } # Stage script in a workspace so cleanup is easy $stage = New-MsixWorkspace "$AppId-$Name" $scriptPath = Join-Path $stage $ScriptFileName try { $genArgs = @{ Name = $Name Parameters = $Parameters OutputPath = $scriptPath } if ($Pfx -and -not $SkipSigning) { $genArgs['Pfx'] = $Pfx $genArgs['PfxPassword'] = $PfxPassword } New-MsixStandardScript @genArgs | Out-Null $startBlock = New-MsixPsfStartScriptConfig -AppId $AppId ` -ScriptPath $ScriptFileName ` -RunOnce:$RunOnce ` -WaitForScriptToFinish:$WaitForScriptToFinish ` -ShowWindow:$ShowWindow ` -RunInVirtualEnvironment:$RunInVirtualEnvironment ` -StopOnScriptError:$StopOnScriptError ` -Timeout $Timeout ` -EndScript:$EndScript $psfArgs = @{ PackagePath = $PackagePath Fixups = @() AppOptions = @($startBlock) AdditionalFiles = @($scriptPath) } if ($Pfx) { $psfArgs['Pfx'] = $Pfx } if ($PfxPassword) { $psfArgs['PfxPassword'] = $PfxPassword } if ($OutputPath) { $psfArgs['OutputPath'] = $OutputPath } if ($SkipSigning) { $psfArgs['SkipSigning'] = $true } Add-MsixPsfV2 @psfArgs } finally { Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue } } # Backward-compatible plural aliases Set-Alias Get-MsixStandardScripts Get-MsixStandardScript |