param( [string]$ProjectPath = (Join-Path $PSScriptRoot "..\..\Tornado3_2026Election\Tornado3_2026Election.csproj"), [ValidateSet("Debug", "Release")] [string]$Configuration = "Release", [string]$Platform = "x64", [string]$RuntimeIdentifier = "win-x64", [ValidatePattern("^\d+\.\d+\.\d+\.\d+$")] [string]$PackageVersion, [switch]$IncrementPackageRevision, [string]$PublicBaseUri = "http://122.34.248.185/msix/", [string]$NasHost = "192.168.200.129", [int]$NasSshPort = 22, [string]$NasUser = $env:NAS_USER, [string]$NasRemotePath = "/volume1/web/msix", [string]$SshKeyPath = $env:NAS_SSH_KEY, [string]$CertificateThumbprint = "E691A33C64DF20A204FFD4F096B9C3EB4B95709C", [string]$CertificateFileName = "Comtrophy_MSIX_Signing.cer", [string]$InstallCertificateScriptPath = (Join-Path $PSScriptRoot "Install-ComtrophyMsixCertificate.ps1"), [switch]$SkipPackageBuild, [switch]$NoUpload, [switch]$NoVerify ) $ErrorActionPreference = "Stop" function Resolve-FullPath { param([Parameter(Mandatory)][string]$Path) if (Test-Path -LiteralPath $Path) { return (Resolve-Path -LiteralPath $Path).Path } $executionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } function Invoke-Checked { param( [Parameter(Mandatory)][string]$FilePath, [Parameter(Mandatory)][string[]]$Arguments ) Write-Host "> $FilePath $($Arguments -join ' ')" & $FilePath @Arguments if ($LASTEXITCODE -ne 0) { throw "$FilePath failed with exit code $LASTEXITCODE." } } function Test-IsUnderDirectory { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Directory ) $normalizedPath = [System.IO.Path]::GetFullPath($Path).TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) $normalizedDirectory = [System.IO.Path]::GetFullPath($Directory).TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) return $normalizedPath.Equals($normalizedDirectory, [System.StringComparison]::OrdinalIgnoreCase) -or $normalizedPath.StartsWith( $normalizedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase ) -or $normalizedPath.StartsWith( $normalizedDirectory + [System.IO.Path]::AltDirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase ) } function Find-NewestFile { param( [Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Filter, [DateTime]$NotBefore = [DateTime]::MinValue, [string]$ExcludeDirectory ) $resolvedExcludeDirectory = if ($ExcludeDirectory -and (Test-Path -LiteralPath $ExcludeDirectory)) { Resolve-FullPath $ExcludeDirectory } else { $null } Get-ChildItem -Path $Root -Recurse -File -Filter $Filter | Where-Object { $_.LastWriteTime -ge $NotBefore -and (-not $resolvedExcludeDirectory -or -not (Test-IsUnderDirectory -Path $_.FullName -Directory $resolvedExcludeDirectory)) } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 } function Find-PackageFileByLeafName { param( [Parameter(Mandatory)][string]$PackageRoot, [Parameter(Mandatory)][string]$LeafName, [string]$PreferredPathFragment, [string]$ExcludeDirectory ) $resolvedExcludeDirectory = if ($ExcludeDirectory -and (Test-Path -LiteralPath $ExcludeDirectory)) { Resolve-FullPath $ExcludeDirectory } else { $null } $candidates = Get-ChildItem -Path $PackageRoot -Recurse -File -Filter $LeafName | Where-Object { -not $resolvedExcludeDirectory -or -not (Test-IsUnderDirectory -Path $_.FullName -Directory $resolvedExcludeDirectory) } if ($PreferredPathFragment) { $preferred = $candidates | Where-Object { $_.FullName -like "*$PreferredPathFragment*" } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($preferred) { return $preferred } } $candidates | Sort-Object LastWriteTime -Descending | Select-Object -First 1 } function Get-UriLeafName { param([Parameter(Mandatory)][string]$Uri) try { return [System.IO.Path]::GetFileName(([System.Uri]$Uri).AbsolutePath) } catch { return [System.IO.Path]::GetFileName($Uri.Replace("/", "\")) } } function Ensure-CertificateFile { param( [Parameter(Mandatory)][string]$CertificatePath, [Parameter(Mandatory)][string]$Thumbprint ) if (Test-Path -LiteralPath $CertificatePath) { return } $certificate = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $Thumbprint } | Select-Object -First 1 if (-not $certificate) { throw "Could not find signing certificate $Thumbprint in Cert:\CurrentUser\My." } Export-Certificate -Cert $certificate -FilePath $CertificatePath -Force | Out-Null } function Get-PackageManifestVersion { param([Parameter(Mandatory)][string]$ManifestPath) if (-not (Test-Path -LiteralPath $ManifestPath)) { throw "Package manifest was not found: $ManifestPath" } $manifestXml = New-Object System.Xml.XmlDocument $manifestXml.Load($ManifestPath) $namespaceManager = New-Object System.Xml.XmlNamespaceManager($manifestXml.NameTable) $namespaceManager.AddNamespace("pkg", $manifestXml.DocumentElement.NamespaceURI) $identityNode = $manifestXml.SelectSingleNode("/pkg:Package/pkg:Identity", $namespaceManager) if (-not $identityNode) { throw "Identity node was not found in $ManifestPath." } $identityNode.GetAttribute("Version") } function Get-NextPackageRevision { param([Parameter(Mandatory)][string]$Version) $parts = $Version.Split(".") if ($parts.Count -ne 4) { throw "Package version must have four parts: $Version" } $numbers = foreach ($part in $parts) { [int]$part } $numbers[3] += 1 $numbers -join "." } function Set-PackageManifestVersion { param( [Parameter(Mandatory)][string]$ManifestPath, [Parameter(Mandatory)][string]$Version ) if (-not (Test-Path -LiteralPath $ManifestPath)) { throw "Package manifest was not found: $ManifestPath" } $manifestXml = New-Object System.Xml.XmlDocument $manifestXml.PreserveWhitespace = $true $manifestXml.Load($ManifestPath) $namespaceManager = New-Object System.Xml.XmlNamespaceManager($manifestXml.NameTable) $namespaceManager.AddNamespace("pkg", $manifestXml.DocumentElement.NamespaceURI) $identityNode = $manifestXml.SelectSingleNode("/pkg:Package/pkg:Identity", $namespaceManager) if (-not $identityNode) { throw "Identity node was not found in $ManifestPath." } $currentVersion = $identityNode.GetAttribute("Version") if ($currentVersion -eq $Version) { Write-Host "Package manifest version is already $Version" return } $identityNode.SetAttribute("Version", $Version) $manifestXml.Save($ManifestPath) Write-Host "Updated package manifest version from $currentVersion to $Version" } function Join-PublicUri { param( [Parameter(Mandatory)][string]$BaseUri, [Parameter(Mandatory)][string]$LeafName ) $normalizedBase = $BaseUri if (-not $normalizedBase.EndsWith("/")) { $normalizedBase += "/" } "$normalizedBase$LeafName" } $projectFullPath = Resolve-FullPath $ProjectPath $projectDirectory = Split-Path -Parent $projectFullPath $manifestPath = Join-Path $projectDirectory "Package.appxmanifest" $packageRoot = Join-Path $projectDirectory "AppPackages" $stagingRoot = Join-Path $packageRoot "msix-publish-flat" $certificatePath = Join-Path $stagingRoot $CertificateFileName $normalizedPublicBaseUri = $PublicBaseUri if (-not $normalizedPublicBaseUri.EndsWith("/")) { $normalizedPublicBaseUri += "/" } if ($PackageVersion -and $IncrementPackageRevision) { throw "Use either PackageVersion or IncrementPackageRevision, not both." } if ($PackageVersion -and $SkipPackageBuild) { throw "PackageVersion requires a new package build. Remove -SkipPackageBuild and run again." } if ($IncrementPackageRevision -and $SkipPackageBuild) { throw "IncrementPackageRevision requires a new package build. Remove -SkipPackageBuild and run again." } if ($IncrementPackageRevision) { $currentPackageVersion = Get-PackageManifestVersion -ManifestPath $manifestPath $PackageVersion = Get-NextPackageRevision -Version $currentPackageVersion Write-Host "Auto-incrementing package version from $currentPackageVersion to $PackageVersion" } if ($PackageVersion) { Set-PackageManifestVersion -ManifestPath $manifestPath -Version $PackageVersion } $signingCertificate = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $CertificateThumbprint } | Select-Object -First 1 if (-not $signingCertificate) { throw "Signing certificate $CertificateThumbprint was not found in Cert:\CurrentUser\My." } if (-not $signingCertificate.HasPrivateKey) { throw "Signing certificate $CertificateThumbprint does not have a private key." } $buildStartedAt = Get-Date if (-not $SkipPackageBuild) { $dotnetArgs = @( "msbuild", $projectFullPath, "/restore", "/t:Build", "/p:Configuration=$Configuration", "/p:Platform=$Platform", "/p:RuntimeIdentifier=$RuntimeIdentifier", "/p:GenerateAppxPackageOnBuild=true", "/p:GenerateAppInstallerFile=true", "/p:AppxPackageSigningEnabled=true", "/p:PackageCertificateThumbprint=$CertificateThumbprint", "/p:AppInstallerUri=$normalizedPublicBaseUri", "/p:AppxBundle=Never" ) Invoke-Checked -FilePath "dotnet" -Arguments $dotnetArgs } if (Test-Path -LiteralPath $stagingRoot) { $resolvedStaging = Resolve-FullPath $stagingRoot $resolvedPackageRoot = Resolve-FullPath $packageRoot if (-not (Test-IsUnderDirectory -Path $resolvedStaging -Directory $resolvedPackageRoot)) { throw "Refusing to clean staging path outside package root: $resolvedStaging" } Get-ChildItem -LiteralPath $stagingRoot -Force | Remove-Item -Recurse -Force } else { New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null } $appInstallerFile = Find-NewestFile -Root $packageRoot -Filter "*_${Platform}.appinstaller" -NotBefore $(if ($SkipPackageBuild) { [DateTime]::MinValue } else { $buildStartedAt.AddMinutes(-2) }) -ExcludeDirectory $stagingRoot if (-not $appInstallerFile) { $appInstallerFile = Find-NewestFile -Root $packageRoot -Filter "*.appinstaller" -ExcludeDirectory $stagingRoot } if (-not $appInstallerFile) { throw "Could not find an .appinstaller file under $packageRoot." } $stagedAppInstallerPath = Join-Path $stagingRoot $appInstallerFile.Name Copy-Item -LiteralPath $appInstallerFile.FullName -Destination $stagedAppInstallerPath -Force $appInstallerXml = New-Object System.Xml.XmlDocument $appInstallerXml.PreserveWhitespace = $true $appInstallerXml.Load($stagedAppInstallerPath) $namespaceManager = New-Object System.Xml.XmlNamespaceManager($appInstallerXml.NameTable) $namespaceManager.AddNamespace("ai", $appInstallerXml.DocumentElement.NamespaceURI) $mainPackageNode = $appInstallerXml.SelectSingleNode("//ai:MainPackage", $namespaceManager) if (-not $mainPackageNode) { throw "MainPackage node was not found in $($appInstallerFile.FullName)." } $mainPackageLeafName = Get-UriLeafName $mainPackageNode.GetAttribute("Uri") $mainPackageFile = Find-PackageFileByLeafName -PackageRoot $packageRoot -LeafName $mainPackageLeafName -ExcludeDirectory $stagingRoot if (-not $mainPackageFile) { $mainPackageFile = Get-ChildItem -Path $packageRoot -Recurse -File -Filter "*.msix" | Where-Object { $_.FullName -notlike "*\Dependencies\*" -and -not (Test-IsUnderDirectory -Path $_.FullName -Directory $stagingRoot) } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 } if (-not $mainPackageFile) { throw "Could not find the main .msix package referenced by $($appInstallerFile.FullName)." } $signature = Get-AuthenticodeSignature -FilePath $mainPackageFile.FullName if ($signature.Status -ne "Valid") { throw "MSIX signature is not valid: $($signature.StatusMessage)" } if ($signature.SignerCertificate.Thumbprint -ne $CertificateThumbprint) { throw "MSIX was signed with $($signature.SignerCertificate.Thumbprint), expected $CertificateThumbprint." } Copy-Item -LiteralPath $mainPackageFile.FullName -Destination (Join-Path $stagingRoot $mainPackageFile.Name) -Force $appInstallerXml.DocumentElement.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $appInstallerFile.Name)) $mainPackageNode.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $mainPackageFile.Name)) $dependencyNodes = $appInstallerXml.SelectNodes("//ai:Dependencies/ai:Package", $namespaceManager) foreach ($dependencyNode in $dependencyNodes) { $dependencyLeafName = Get-UriLeafName $dependencyNode.GetAttribute("Uri") $architecture = $dependencyNode.GetAttribute("ProcessorArchitecture") $preferredFragment = if ($architecture) { "Dependencies\$architecture" } else { $null } $dependencyFile = Find-PackageFileByLeafName -PackageRoot $packageRoot -LeafName $dependencyLeafName -PreferredPathFragment $preferredFragment -ExcludeDirectory $stagingRoot if (-not $dependencyFile) { throw "Could not find dependency package $dependencyLeafName." } Copy-Item -LiteralPath $dependencyFile.FullName -Destination (Join-Path $stagingRoot $dependencyFile.Name) -Force $dependencyNode.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $dependencyFile.Name)) } $appInstallerXml.Save($stagedAppInstallerPath) Ensure-CertificateFile -CertificatePath $certificatePath -Thumbprint $CertificateThumbprint if (Test-Path -LiteralPath $InstallCertificateScriptPath) { Copy-Item -LiteralPath $InstallCertificateScriptPath -Destination (Join-Path $stagingRoot (Split-Path -Leaf $InstallCertificateScriptPath)) -Force } $stagedFiles = Get-ChildItem -Path $stagingRoot -File | Sort-Object Name Write-Host "" Write-Host "Prepared MSIX deployment files:" $stagedFiles | ForEach-Object { Write-Host (" - {0} ({1:N0} bytes)" -f $_.Name, $_.Length) } if (-not $NoUpload) { if (-not $NasUser) { throw "NasUser was not provided. Pass -NasUser or set NAS_USER." } $sshArgs = @() if ($NasSshPort -ne 22) { $sshArgs += @("-p", [string]$NasSshPort) } if ($SshKeyPath) { $sshArgs += @("-i", (Resolve-FullPath $SshKeyPath)) } $sshArgs += @("${NasUser}@${NasHost}", "mkdir -p '$NasRemotePath'") Invoke-Checked -FilePath "ssh" -Arguments $sshArgs $scpArgs = @() if ($NasSshPort -ne 22) { $scpArgs += @("-P", [string]$NasSshPort) } if ($SshKeyPath) { $scpArgs += @("-i", (Resolve-FullPath $SshKeyPath)) } $scpArgs += $stagedFiles.FullName $scpArgs += "${NasUser}@${NasHost}:$NasRemotePath/" Invoke-Checked -FilePath "scp" -Arguments $scpArgs } if (-not $NoVerify) { foreach ($file in $stagedFiles) { $uri = Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $file.Name try { $response = Invoke-WebRequest -Uri $uri -Method Head -UseBasicParsing -TimeoutSec 20 } catch { Write-Warning "HEAD failed for $uri. Trying GET." $response = Invoke-WebRequest -Uri $uri -UseBasicParsing -TimeoutSec 20 } if ($response.StatusCode -lt 200 -or $response.StatusCode -gt 299) { throw "Verification failed for $uri with status $($response.StatusCode)." } if (-not $NoUpload) { $contentLengthHeader = $response.Headers["Content-Length"] | Select-Object -First 1 if ($contentLengthHeader) { $remoteLength = [long]$contentLengthHeader if ($remoteLength -ne $file.Length) { throw "Verification failed for $uri. Remote length $remoteLength does not match local length $($file.Length)." } } else { Write-Warning "No Content-Length header returned for $uri; status verification only." } } Write-Host "Verified $uri" } } Write-Host "Staging directory:" Write-Host $stagingRoot Write-Host "" Write-Host "App Installer URL:" Write-Host (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $appInstallerFile.Name)