This commit is contained in:
2026-05-13 11:21:48 +09:00
parent 960163dad8
commit 8b5c92194f
66 changed files with 12393 additions and 939 deletions

View File

@@ -0,0 +1,99 @@
param(
[string]$CertificatePath = (Join-Path $PSScriptRoot "Comtrophy_MSIX_Signing.cer"),
[string]$CertificateUri = "http://122.34.248.185/msix/Comtrophy_MSIX_Signing.cer",
[ValidateSet("LocalMachine", "CurrentUser")]
[string]$StoreScope = "LocalMachine",
[switch]$NoElevate,
[switch]$NoPause
)
$ErrorActionPreference = "Stop"
$ExpectedThumbprint = "E691A33C64DF20A204FFD4F096B9C3EB4B95709C"
$downloadedCertificate = $false
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Quote-Argument {
param([Parameter(Mandatory)][string]$Value)
'"' + $Value.Replace('"', '\"') + '"'
}
if ($StoreScope -eq "LocalMachine" -and -not (Test-IsAdministrator)) {
if ($NoElevate) {
throw "LocalMachine certificate import requires an elevated PowerShell session."
}
if (-not $PSCommandPath) {
throw "LocalMachine certificate import requires an elevated PowerShell session."
}
Write-Host "Restarting as administrator to trust the MSIX signing certificate for this PC..."
$arguments = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", (Quote-Argument $PSCommandPath),
"-CertificatePath", (Quote-Argument $CertificatePath),
"-CertificateUri", (Quote-Argument $CertificateUri),
"-StoreScope", $StoreScope,
"-NoElevate"
)
if ($NoPause) {
$arguments += "-NoPause"
}
$process = Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -Verb RunAs -Wait -PassThru
exit $process.ExitCode
}
if (-not (Test-Path -LiteralPath $CertificatePath)) {
$certificateDirectory = Split-Path -Parent $CertificatePath
if ($certificateDirectory -and -not (Test-Path -LiteralPath $certificateDirectory)) {
New-Item -ItemType Directory -Path $certificateDirectory -Force | Out-Null
}
Write-Host "Downloading MSIX signing certificate..."
Invoke-WebRequest -Uri $CertificateUri -OutFile $CertificatePath -UseBasicParsing
$downloadedCertificate = $true
}
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertificatePath)
if ($certificate.Thumbprint -ne $ExpectedThumbprint) {
throw "Unexpected certificate thumbprint. Expected $ExpectedThumbprint but got $($certificate.Thumbprint)."
}
$stores = @(
"Cert:\$StoreScope\TrustedPeople",
"Cert:\$StoreScope\Root"
)
foreach ($store in $stores) {
$existing = Get-ChildItem -Path $store | Where-Object { $_.Thumbprint -eq $ExpectedThumbprint }
if ($existing) {
Write-Host "Certificate already trusted in $store"
continue
}
Write-Host "Importing certificate into $store"
Import-Certificate -FilePath $CertificatePath -CertStoreLocation $store | Out-Null
}
Write-Host "MSIX signing certificate is trusted in $StoreScope for thumbprint $ExpectedThumbprint."
Write-Host ""
Write-Host "Certificate setup is complete."
Write-Host "Install the app separately with this link:"
Write-Host "http://122.34.248.185/msix/Tornado3_2026Election_x64.appinstaller"
if ($downloadedCertificate) {
Write-Host "Certificate saved to $CertificatePath"
}
if (-not $NoPause) {
Write-Host ""
Read-Host "Press Enter to close this window"
}

View File

@@ -0,0 +1,475 @@
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 <user> 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)

120
tools/msix/README.md Normal file
View File

@@ -0,0 +1,120 @@
# MSIX publish workflow
This folder contains the scripts used to build the MSIX package, flatten the
App Installer deployment files, upload them to the Synology NAS web folder, and
verify the public download URLs.
## Files
- `Publish-MsixToNas.ps1`: builds the app package, stages the deployable files,
uploads them to the NAS with SSH/SCP, and verifies the public URLs.
- `Install-ComtrophyMsixCertificate.ps1`: installs the MSIX signing certificate
for the current Windows user, then optionally opens the appinstaller URL.
## First-time NAS SSH setup
1. Create or choose a NAS user that can write to `/volume1/web/msix`.
2. Enable SSH on the Synology NAS.
3. Optional but recommended: create an SSH key for publishing.
```powershell
ssh-keygen -t ed25519 -f $env:USERPROFILE\.ssh\nas_msix_ed25519
type $env:USERPROFILE\.ssh\nas_msix_ed25519.pub
```
Add the printed public key to the NAS user's `~/.ssh/authorized_keys`.
Test the connection:
```powershell
ssh -i $env:USERPROFILE\.ssh\nas_msix_ed25519 <nas-user>@192.168.200.129 "ls -ld /volume1/web/msix"
```
## Publish a new build
Set the NAS login once in the current PowerShell session:
```powershell
$env:NAS_USER = "<nas-user>"
$env:NAS_SSH_KEY = "$env:USERPROFILE\.ssh\nas_msix_ed25519"
```
Build, package, upload, and verify:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Release -IncrementPackageRevision
```
Use `-IncrementPackageRevision` for normal approved deployments. It reads the
current `Package.appxmanifest` version and increments the fourth version part
before building. App Installer uses the MSIX package version to decide whether a
client should receive an update.
In Codex sessions, this is the command to run only after the user explicitly
approves publishing the finished work.
To publish a specific version instead:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Release -PackageVersion 1.0.3.2
```
To upload the latest already-built package without rebuilding:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Debug -SkipPackageBuild
```
To prepare files locally without uploading:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Debug -SkipPackageBuild -NoUpload
```
The default public base URL is:
```text
http://122.34.248.185/msix/
```
If the deployment should use the Synology DDNS name instead, pass:
```powershell
-PublicBaseUri "http://comtropy.synology.me/msix/"
```
## Installer link
After publish, the installer URL is:
```text
http://122.34.248.185/msix/Tornado3_2026Election_x64.appinstaller
```
The user PC must trust the signing certificate before installing the MSIX for
the first time. The script only installs the certificate; it does not run the
app installer. Approve the UAC administrator prompt when Windows asks:
```powershell
powershell -ExecutionPolicy Bypass -File .\Install-ComtrophyMsixCertificate.ps1
```
To run it directly from the NAS on a target PC:
```powershell
$script = Join-Path $env:TEMP "Install-ComtrophyMsixCertificate.ps1"
Invoke-WebRequest "http://122.34.248.185/msix/Install-ComtrophyMsixCertificate.ps1" -OutFile $script
powershell -ExecutionPolicy Bypass -File $script
```
After the certificate setup is complete, open the appinstaller link once to
install the app. After installation, run the app from the Windows Start menu, not
from the appinstaller link.
If installation fails with `0x800B0109`, confirm the certificate is present in
both local computer stores:
```powershell
Get-ChildItem Cert:\LocalMachine\TrustedPeople, Cert:\LocalMachine\Root |
Where-Object Thumbprint -eq "E691A33C64DF20A204FFD4F096B9C3EB4B95709C"
```