<# .SYNOPSIS Builds the CursorLang MSIX package. .DESCRIPTION Neither Visual Studio nor the Windows SDK is needed: makeappx and signtool arrive as a NuGet package (Tools\SdkTools.csproj) and the application is built by the plain .NET SDK. The package comes out of here in one of two shapes. Left unsigned it goes to Partner Center as it is — the Store puts its own signature on it and does the rest. Signed with -CertificateThumbprint it is a package anyone can install from a release, because Windows only takes a package whose signature it trusts. The application is published with its own copy of .NET: Windows does not carry one, and MSIX cannot install the runtime as a package dependency. .PARAMETER IdentityName The identity of the package — from Partner Center, the "Product identity" section. It is handed out there together with the reserved application name; the default is only good enough for a check on your own machine. .PARAMETER PackageSuffix Goes at the end of the file names. The two builds of a release differ in the identity inside them and in nothing a file listing shows, so the one meant for the Store is told apart by a suffix of its own. .PARAMETER CertificateThumbprint Signs the packages with the certificate of this thumbprint from the personal store of the current user. The private key is none of this script's business: signtool asks the store for it, and behind a cloud certificate — eSigner CKA among them — the store answers over the network. .PARAMETER Install Puts the package onto this machine to see it working. Developer mode has to be on; a signature is not needed, because what gets registered is the layout the package is made of rather than the package file. .EXAMPLE # Build and install in one go, to click through the application pwsh -File Packaging\build-msix.ps1 -Install .EXAMPLE # A build for the Store — the identity comes from Partner Center pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 -PackageSuffix store ` -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..." .EXAMPLE # A build for a release — the publisher is the subject of the certificate pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` -Publisher "CN=Aleksandr Neichev, O=Aleksandr Neichev, C=KZ" ` -CertificateThumbprint A1B2C3D4E5F60718293A4B5C6D7E8F9012345678 #> [CmdletBinding()] param( # Four numbers, the last one has to be 0: that is what the Store requires [string] $Version = '1.0.0.0', [string] $IdentityName = 'CursorLang', [string] $Publisher = 'CN=Aleksandr Neichev', [string] $PublisherDisplayName = 'Aleksandr Neichev', [ValidateSet('x64', 'arm64')] [string[]] $Architectures = @('x64', 'arm64'), [string] $PackageSuffix, [string] $CertificateThumbprint, # SSL.com's timestamp server, to go with the certificate the pipeline signs # with. A signature without a timestamp is only good while the certificate # is: the day it expires the package stops installing everywhere at once [string] $TimestampUrl = 'http://ts.ssl.com', [switch] $Install, [string] $OutputPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $root = $PSScriptRoot $repository = Split-Path -Parent $root $agentProject = Join-Path $repository 'CursorLang.Agent\CursorLang.Agent.csproj' $settingsProject = Join-Path $repository 'CursorLang.Settings\CursorLang.Settings.csproj' $assets = Join-Path $root 'Assets' $manifestTemplate = Join-Path $root 'AppxManifest.xml' $toolsProject = Join-Path $root 'Tools\SdkTools.csproj' if (-not $OutputPath) { $OutputPath = Join-Path $repository 'artifacts' } elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) { # Windows names the folder a package was registered from in full, and the # layout is looked for by that name below. A relative path would never be # found there, and the registration of a build gone by would be left behind $OutputPath = Join-Path (Get-Location).Path $OutputPath } $layoutRoot = Join-Path $OutputPath 'layout' $packagesPath = Join-Path $OutputPath 'packages' if ($Version -notmatch '^\d+\.\d+\.\d+\.0$') { throw "The version '$Version' does not fit: the Store takes four numbers ending in zero, for example 1.0.0.0." } if (-not (Test-Path $assets)) { throw "No logos found in '$assets'. Run Packaging\New-Assets.ps1 first." } if ($CertificateThumbprint) { # Both things below are checked before the build for the same reason the # ones under -Install are: the build takes minutes and neither answer # changes while it runs $certificate = Get-ChildItem "Cert:\CurrentUser\My\$CertificateThumbprint" -ErrorAction SilentlyContinue if (-not $certificate) { throw "No certificate with the thumbprint '$CertificateThumbprint' in the personal store of this user. A cloud certificate has to be loaded into the store first — eSigner CKA is what does that." } # The Publisher of a package is not a name of the publisher's choosing: it # is the subject of the certificate the package is signed with, letter for # letter. signtool turns down a package that claims any other, and it does # so at the very end — after everything has already been built if ($certificate.Subject -ne $Publisher) { throw "The publisher '$Publisher' is not the subject of the certificate, which reads '$($certificate.Subject)'. A package carries the name of whoever signs it." } } if ($Install) { # Checked before the build rather than after it: the build takes minutes, and # this does not get any truer while it runs $developerMode = Get-ItemPropertyValue ` 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' ` -Name 'AllowDevelopmentWithoutDevLicense' -ErrorAction SilentlyContinue if ($developerMode -ne 1) { throw 'Installing needs developer mode: Settings - System - For developers - Developer mode. Without a signature Windows registers a package no other way.' } } function Invoke-Tool { <# .SYNOPSIS Runs a program and fails the build if it returned an error. .DESCRIPTION A wrapper of our own is needed because PowerShell does not treat the failure of an external program as an error and quietly moves on. #> param( [string] $Path, [string[]] $Arguments ) & $Path @Arguments if ($LASTEXITCODE -ne 0) { throw "'$([System.IO.Path]::GetFileName($Path))' exited with code $LASTEXITCODE." } } function Get-SdkToolsPath { <# .SYNOPSIS Returns the folder of the restored Windows SDK Build Tools package. .DESCRIPTION The path is asked of MSBuild rather than searched for in the package cache: the project pins one version of the package, and MSBuild names exactly it. A search would also find the versions left over from other builds. #> $path = (& dotnet msbuild $toolsProject -getProperty:PkgMicrosoft_Windows_SDK_BuildTools -nologo) | Where-Object { $_ -and $_.Trim() } | Select-Object -Last 1 if ($LASTEXITCODE -ne 0 -or -not $path -or -not (Test-Path $path.Trim())) { throw 'The Windows SDK Build Tools package folder could not be found. Did the restore go through?' } return $path.Trim() } function Get-SdkTool { <# .SYNOPSIS Returns the path of a program from the Windows SDK Build Tools package. #> param( [string] $Name, [string] $PackagePath ) # The bitness of the program has nothing to do with the bitness of the # package being built: we take the one that runs on this machine $host64 = if ([Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' } $tool = Get-ChildItem (Join-Path $PackagePath 'bin') -Recurse -Filter $Name -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match "\\$host64\\" } | Select-Object -First 1 if (-not $tool) { throw "'$Name' was not found in the Windows SDK Build Tools package." } return $tool.FullName } function Invoke-Signing { <# .SYNOPSIS Signs a package with the certificate the build was given. #> param( [string] $Path, [string] $SignTool ) Write-Host "Signing $([System.IO.Path]::GetFileName($Path))..." -ForegroundColor Cyan Invoke-Tool -Path $SignTool -Arguments @( 'sign', '/fd', 'sha256', '/tr', $TimestampUrl, '/td', 'sha256', '/sha1', $CertificateThumbprint, $Path ) } Write-Host 'Fetching the Windows SDK programs...' -ForegroundColor Cyan Invoke-Tool -Path 'dotnet' -Arguments @('restore', $toolsProject, '--nologo') $sdkTools = Get-SdkToolsPath $makeappx = Get-SdkTool -Name 'makeappx.exe' -PackagePath $sdkTools $signtool = if ($CertificateThumbprint) { Get-SdkTool -Name 'signtool.exe' -PackagePath $sdkTools } else { $null } # A package registered out of the layout runs straight from that folder, and # the folder is about to be wiped. Left in place, the registration would point # at files that are gone — an application that neither starts nor uninstalls # the usual way. This happens whatever the build was asked for, so -Install has # no say in it. # # Only a registration made from this very folder is removed. A copy installed # from the Store answers to the same name and is none of our business foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) { $location = $installed.InstallLocation if (-not $location) { continue } if (-not $location.TrimEnd('\').StartsWith($layoutRoot.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { continue } Write-Host "Removing the registered $($installed.PackageFullName)..." -ForegroundColor Cyan Remove-AppxPackage -Package $installed.PackageFullName } Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null $suffix = if ($PackageSuffix) { "-$PackageSuffix" } else { '' } $built = @() $layout = Join-Path $layoutRoot 'x64' foreach ($half in @($agentProject, $settingsProject)) { Invoke-Tool -Path 'dotnet' -Arguments @( 'publish', $half, '--configuration', 'Release', '--runtime', 'win-x64', '--self-contained', 'true', "-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))", '--output', $layout, '--nologo' ) } # Debug symbols have no place in the package: they take up room, the user # has no use for them, and for crash reports the Store takes them separately Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force $manifest = (Get-Content $manifestTemplate -Raw). Replace('{IdentityName}', $IdentityName). Replace('{Publisher}', $Publisher). Replace('{PublisherDisplayName}', $PublisherDisplayName). Replace('{Version}', $Version). Replace('{Architecture}', 'x64') Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8 $package = Join-Path $packagesPath "CursorLang-$Version-$architecture$suffix.msix" Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package) $built += $package } $result = $built[0] if ($built.Count -gt 1) { Write-Host 'Bringing the architectures into one package...' -ForegroundColor Cyan # makeappx bundle takes everything from a folder, so the separate packages # are gathered into one of their own first — otherwise the results of # earlier builds would end up in the bundle $bundleInput = Join-Path $OutputPath 'bundle' Remove-Item $bundleInput -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null $built | ForEach-Object { Copy-Item $_ -Destination $bundleInput } $result = Join-Path $packagesPath "CursorLang-$Version$suffix.msixbundle" Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version) Remove-Item $bundleInput -Recurse -Force } if ($CertificateThumbprint) { # Signing comes after the bundle rather than before it: makeappx copies the # packages into the bundle as they are, and a signature on what lies inside # says nothing about the bundle around it. Windows asks the outer file, so # every file that leaves here is signed on its own — each one of them is a # package someone may install foreach ($package in (@($built) + @($result) | Select-Object -Unique)) { Invoke-Signing -Path $package -SignTool $signtool } } if ($Install) { Write-Host 'Installing...' -ForegroundColor Cyan # The layout is registered rather than the package file: the two hold the # same thing, but a package file Windows only installs when it is signed Add-AppxPackage -Register (Join-Path $layout 'AppxManifest.xml') } Write-Host '' Write-Host 'Done.' -ForegroundColor Green Write-Host " $result" Write-Host '' if ($CertificateThumbprint) { Write-Host ' This file is signed: Windows installs it on any machine.' } else { Write-Host ' This file is uploaded to Partner Center as it is.' } if ($Install) { Write-Host '' Write-Host ' The application is installed and runs out of the layout folder, so' Write-Host ' building again would pull the files out from under it. This script' Write-Host ' takes care of that itself; to remove the application by hand:' Write-Host " Remove-AppxPackage (Get-AppxPackage -Name $IdentityName).PackageFullName" }