369 lines
14 KiB
PowerShell
369 lines
14 KiB
PowerShell
<#
|
|
.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
|
|
# A check on your own machine: your architecture alone
|
|
pwsh -File Packaging\build-msix.ps1 -Architectures x64
|
|
|
|
.EXAMPLE
|
|
# Build and install in one go, to click through the application
|
|
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -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) {
|
|
# Both things below are checked before the build rather than after it: the
|
|
# build takes minutes, and neither of them gets 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.'
|
|
}
|
|
|
|
$machineArchitecture = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
|
|
|
|
if ($Architectures -notcontains $machineArchitecture) {
|
|
throw "This machine is $machineArchitecture, and that architecture is not being built. Add it to -Architectures, or drop -Install."
|
|
}
|
|
}
|
|
|
|
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 = @()
|
|
|
|
foreach ($architecture in $Architectures) {
|
|
Write-Host "Building $architecture..." -ForegroundColor Cyan
|
|
|
|
$layout = Join-Path $layoutRoot $architecture
|
|
|
|
foreach ($half in @($agentProject, $settingsProject)) {
|
|
Invoke-Tool -Path 'dotnet' -Arguments @(
|
|
'publish', $half,
|
|
'--configuration', 'Release',
|
|
'--runtime', "win-$architecture",
|
|
'--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}', $architecture)
|
|
|
|
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 the $machineArchitecture build..." -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 $layoutRoot "$machineArchitecture\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"
|
|
}
|