modified release pipeline #4

Merged
alex merged 3 commits from add-package-sign-to-pipeline into master 2026-08-13 11:17:23 +00:00
9 changed files with 337 additions and 86 deletions
Showing only changes of commit 196a46f098 - Show all commits
+59
View File
@@ -84,6 +84,10 @@ jobs:
# reserves the last one, so it carries nothing the tag could tell
"version=$($tag.Substring(1)).0" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
# The installer answers to nobody about a fourth number and takes the
# tag as it is
"plain=$($tag.Substring(1))" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Show the toolchain
run: dotnet --info
@@ -128,6 +132,12 @@ jobs:
./Packaging/build-msix.ps1 @arguments
# The other half of the release: the same application as an ordinary
# installer, for handing round outside the Store. Nobody signs it, so
# SmartScreen warns about it — see Packaging\installer.iss
- name: Build the installer
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
# The artifact is where the package waits to be uploaded to Partner Center
- name: Keep the package
uses: actions/upload-artifact@v4
@@ -135,3 +145,52 @@ jobs:
name: msix-${{ steps.version.outputs.version }}
path: artifacts/packages/
if-no-files-found: error
# The .wixpdb next to each installer is left out on purpose: it is of use
# only when something has to be traced back to the WiX source
- name: Keep the installer
uses: actions/upload-artifact@v4
with:
name: installer-${{ steps.version.outputs.plain }}
path: artifacts/installers/*.msi
if-no-files-found: error
# Only the installers go into the release. The MSIX stays in the artifacts
# of the run: unsigned, it installs nowhere, and its one destination is
# Partner Center
- name: Publish the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
$ErrorActionPreference = 'Stop'
# The GITHUB_ names are what Gitea itself hands to the workflow — its
# actions repeat those of GitHub, and the addresses in them point at
# this Gitea instance. GITHUB_API_URL used not to reach the steps at
# all, so the address is put together from the server one when empty
$root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" }
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# Gitea makes a release of its own for a pushed tag, so the release is
# looked up first and only made when it is not there
$release = $null
try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { }
if (-not $release) {
$body = @{ tag_name = $env:TAG; name = $env:TAG; draft = $false; prerelease = $false } | ConvertTo-Json
$release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body
}
foreach ($file in Get-ChildItem artifacts/installers -File -Filter *.msi) {
# A tag can be pushed again after it was deleted; the old file of
# the same name is dropped, otherwise the upload is refused
$existing = $release.assets | Where-Object { $_.name -eq $file.Name }
foreach ($asset in $existing) {
Invoke-RestMethod "$api/$($release.id)/assets/$($asset.id)" -Method Delete -Headers $headers | Out-Null
}
Write-Host "Uploading $($file.Name)"
Invoke-RestMethod "$api/$($release.id)/assets?name=$($file.Name)" -Method Post -Headers $headers -Form @{ attachment = $file } | Out-Null
}
+2 -2
View File
@@ -14,10 +14,10 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
+1 -1
View File
@@ -11,7 +11,7 @@
<RootNamespace>CursorLang.Core</RootNamespace>
<AssemblyName>CursorLang.Core</AssemblyName>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
@@ -16,10 +16,10 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
+152
View File
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The installer for handing the application round outside the Store.
The Store gets an MSIX and signs it itself; this one nobody signs, so Windows
warns about an unknown publisher and the user has to insist. That is the price
of an unsigned build, and short of a certificate from a trusted authority there
is no way round it.
Built by build-installer.ps1 — it publishes both halves of the application and
passes the version, the architecture and the folders in as preprocessor
variables.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"
xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
<Package
Name="CursorLang"
Manufacturer="Aleksandr Neichev"
Version="$(var.Version)"
UpgradeCode="B7E4C9A2-5D31-4F86-9E27-3A1C8B0D6F45"
Compressed="yes"
Scope="perUser">
<!--
Installing for the current user alone asks for no administrator rights,
and so for no consent dialog either. The application needs nothing
beyond the user's own account, and one awkward question the less is
worth more here than a copy shared by everyone who signs in.
MSI was designed around installing for the whole machine, and its
validation rules still say so: the ICE checks this trips over are
turned off in the project file, not answered.
-->
<!--
Everything goes inside the .msi. Left to itself WiX puts the cabinet
beside the file, and an installer that has to travel with a second
file next to it is no use to anyone it is sent to
-->
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<MajorUpgrade DowngradeErrorMessage="A newer version of CursorLang is already installed." />
<!--
Both halves may be running, and both hold on to files in the install
folder. Named here, they are asked to close instead of the install
failing halfway through on a file it cannot replace
-->
<util:CloseApplication Id="CloseAgent" Target="CursorLang.exe"
CloseMessage="yes" RebootPrompt="no" />
<util:CloseApplication Id="CloseSettings" Target="CursorLang.Settings.exe"
CloseMessage="yes" RebootPrompt="no" />
<Icon Id="CursorLangIcon" SourceFile="$(var.IconFile)" />
<Property Id="ARPPRODUCTICON" Value="CursorLangIcon" />
<Property Id="ARPNOREPAIR" Value="1" />
<!-- Only the folder is asked about: everything here is installed, always -->
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<WixVariable Id="WixUILicenseRtf" Value="$(var.LicenseFile)" />
<StandardDirectory Id="LocalAppDataFolder">
<Directory Id="ProgramsFolder" Name="Programs">
<Directory Id="INSTALLFOLDER" Name="CursorLang" />
</Directory>
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="ShortcutFolder" Name="CursorLang" />
</StandardDirectory>
<Feature Id="Main" Title="CursorLang" Level="1">
<ComponentGroupRef Id="Payload" />
<ComponentRef Id="StartMenuShortcut" />
</Feature>
<!--
The whole published folder: two executables, the libraries and the
runtime. Listing them one by one would mean rewriting this file every
time the runtime changes shape
-->
<ComponentGroup Id="Payload" Directory="INSTALLFOLDER">
<Files Include="$(var.PayloadDir)\**" />
</ComponentGroup>
<!--
The shortcut starts the agent — the half that lives in the tray and
shows the layout. The settings window opens from its menu, so it needs
no shortcut of its own.
The key path is a registry value rather than the shortcut file: for
anything installed into the user's profile Windows Installer wants it
that way, and a shortcut cannot serve as one
-->
<Component Id="StartMenuShortcut" Directory="ShortcutFolder" Guid="D4A81C36-7E52-4B19-A0F3-6C2E9D5B8471">
<Shortcut Id="CursorLangShortcut"
Name="CursorLang"
Description="Shows the keyboard layout at the cursor"
Target="[INSTALLFOLDER]CursorLang.exe"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="RemoveShortcutFolder" Directory="ShortcutFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\CursorLang" Name="Shortcut"
Type="integer" Value="1" KeyPath="yes" />
</Component>
<!--
The startup entry is written by the application itself, when startup is
switched on in its settings, and has to go when the application does:
left behind, it would point at an executable that is gone and Windows
would go on listing it in the startup list for good.
RemoveRegistryValue would have been the obvious way to say that, but it
means the opposite of what it sounds like — it wipes the value as the
product is installed, which would quietly switch startup off for anyone
who had turned it on and then upgraded. Hence reg.exe on the way out.
UPGRADINGPRODUCTCODE tells an uninstall apart from the removal of the
old version during an upgrade: the second one has to leave the entry
alone, or upgrading would cost the user their startup setting anyway.
-->
<CustomAction Id="ForgetStartup" Directory="INSTALLFOLDER" Execute="deferred"
Impersonate="yes" Return="ignore"
ExeCommand="&quot;[SystemFolder]reg.exe&quot; delete &quot;HKCU\Software\Microsoft\Windows\CurrentVersion\Run&quot; /v CursorLang /f" />
<CustomAction Id="ForgetStartupApproval" Directory="INSTALLFOLDER" Execute="deferred"
Impersonate="yes" Return="ignore"
ExeCommand="&quot;[SystemFolder]reg.exe&quot; delete &quot;HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run&quot; /v CursorLang /f" />
<InstallExecuteSequence>
<Custom Action="ForgetStartup" Before="RemoveFiles"
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
<Custom Action="ForgetStartupApproval" After="ForgetStartup"
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
</InstallExecuteSequence>
<!-- Offered at the end of the wizard, the way an installer usually does -->
<Property Id="WixShellExecTarget" Value="[#CursorLang.exe]" />
<CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
<UI>
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication"
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed" />
</UI>
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Start CursorLang" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
</Package>
</Wix>
+3
View File
@@ -5,6 +5,9 @@
meant to be built — build-msix.ps1 restores it and takes the program from the
packages folder. It is kept out of the build in CursorLang.sln for the same
reason.
The installer needs nothing from here: WiX arrives with its own project, at
Installer\CursorLang.wixproj.
-->
<Project Sdk="Microsoft.NET.Sdk">
+30 -69
View File
@@ -25,13 +25,9 @@
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
pwsh -File Packaging\build-msix.ps1 -Install
.EXAMPLE
# A build for the Store — the identity comes from Partner Center
@@ -47,9 +43,6 @@ param(
[string] $Publisher = 'CN=Aleksandr Neichev',
[string] $PublisherDisplayName = 'Aleksandr Neichev',
[ValidateSet('x64', 'arm64')]
[string[]] $Architectures = @('x64', 'arm64'),
[switch] $Install,
[string] $OutputPath
@@ -86,9 +79,8 @@ if (-not (Test-Path $assets)) {
}
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
# 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
@@ -96,12 +88,6 @@ if ($Install) {
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 {
@@ -198,71 +184,46 @@ foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) {
Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null
$built = @()
Write-Host 'Building...' -ForegroundColor Cyan
foreach ($architecture in $Architectures) {
Write-Host "Building $architecture..." -ForegroundColor Cyan
$layout = Join-Path $layoutRoot 'x64'
$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.msix"
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package)
$built += $package
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'
)
}
$result = $built[0]
# 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
if ($built.Count -gt 1) {
Write-Host 'Bringing the architectures into one package...' -ForegroundColor Cyan
Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
# 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 }
$manifest = (Get-Content $manifestTemplate -Raw).
Replace('{IdentityName}', $IdentityName).
Replace('{Publisher}', $Publisher).
Replace('{PublisherDisplayName}', $PublisherDisplayName).
Replace('{Version}', $Version).
Replace('{Architecture}', 'x64')
$result = Join-Path $packagesPath "CursorLang-$Version.msixbundle"
Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version)
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
Remove-Item $bundleInput -Recurse -Force
}
$result = Join-Path $packagesPath "CursorLang-$Version-x64.msix"
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $result)
if ($Install) {
Write-Host "Installing the $machineArchitecture build..." -ForegroundColor Cyan
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 $layoutRoot "$machineArchitecture\AppxManifest.xml")
Add-AppxPackage -Register (Join-Path $layout 'AppxManifest.xml')
}
Write-Host ''
+44 -6
View File
@@ -265,17 +265,19 @@ Identity берётся из переменных репозитория, а е
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
powershell -File Packaging\New-Assets.ps1
# Проверка на своей машине: только своя архитектура
powershell -File Packaging\build-msix.ps1 -Architectures x64
# Проверка на своей машине
powershell -File Packaging\build-msix.ps1
# Для Partner Center — identity та, что зарезервирована там
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
```
Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
Center как есть.
Результат — `artifacts\packages\CursorLang-<версия>-x64.msix`. Он загружается в
Partner Center как есть.
Собирается только x64. Сборка под arm64 удвоила бы вес каждого релиза ради
машин, которые и так выполняют x64 через эмуляцию.
Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки
на эту машину вместо пакета регистрируется layout — см. `-Install` ниже.
@@ -286,7 +288,7 @@ MSIX не может установить среду выполнения как
Чтобы посмотреть, как пакет работает на этой машине, соберите его с `-Install`:
```powershell
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
pwsh -File Packaging\build-msix.ps1 -Install
```
Приложение появится в меню «Пуск» как любое установленное. Регистрируется не сам
@@ -302,3 +304,39 @@ pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
```powershell
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
```
## Сборка установщика
То же приложение собирается и обычным MSI — чтобы раздавать помимо Store: пока
Store не вынес решение или если так его и не вынесет. Ставить ничего, кроме .NET
SDK, не нужно: WiX приезжает пакетом NuGet, как и `makeappx`.
```powershell
# Собрать и запустить — посмотреть глазами пользователя
pwsh -File Packaging\build-installer.ps1 -Install
# Всё, что нужно релизу
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
```
Результат — `artifacts\installers\CursorLang-<версия>-x64.msi`. Всё лежит внутри
`.msi` — отдельного архива рядом с ним нет.
Устанавливается только для текущего пользователя, в
`%LOCALAPPDATA%\Programs\CursorLang`, поэтому не просит ни прав администратора,
ни подтверждения. Удаление идёт через «Параметры» — «Приложения», как у любой
программы, и уносит с собой запись автозапуска: иначе Windows продолжала бы
показывать в автозагрузке приложение, которого уже нет.
Установщик никто не подписывает, поэтому Windows предупреждает о неизвестном
издателе и пользователю приходится настоять. Покупка сертификата это сразу не
снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока
приложение не наберёт установок.
Про проект WiX стоит знать две вещи, прежде чем его править. Он закреплён на WiX
5, а не на нынешней 7: начиная с шестой версии инструмент требует принимать
лицензию Open Source Maintenance Fee — бесплатную при доходе меньше $10 000 в
год, но принимать её должен человек, а не сборочный скрипт. И он отключает три
проверки ICE: MSI по-прежнему исходит из установки на всю машину, а установка в
профиль пользователя нарушает правила, которые описывают ровно то, что здесь и
задумано.
+44 -6
View File
@@ -259,17 +259,19 @@ to submitting for certification — is written up in
# One-off: draw the logos and the exe icon (already committed, rerun after edits)
powershell -File Packaging\New-Assets.ps1
# A check on your own machine: your architecture alone
powershell -File Packaging\build-msix.ps1 -Architectures x64
# A check on your own machine
powershell -File Packaging\build-msix.ps1
# For Partner Center — the identity is the one reserved there
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
```
The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64
and arm64; next to it lie the packages of single architectures. Upload the bundle
to Partner Center as it is.
The result is `artifacts\packages\CursorLang-<version>-x64.msix`. Upload it to
Partner Center as it is.
Only x64 is built. An arm64 build would double the size of every release for the
sake of machines that run the x64 one under emulation anyway.
Nothing here is signed: the Store signs the package itself, and for installing it
on this machine the layout is registered instead — see `-Install` below.
@@ -280,7 +282,7 @@ MSIX cannot install a runtime as a package dependency.
To see the package working on this machine, build it with `-Install`:
```powershell
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
pwsh -File Packaging\build-msix.ps1 -Install
```
The application then shows up in the Start menu like any installed one. What
@@ -296,3 +298,39 @@ To remove it by hand:
```powershell
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
```
## Building the installer
The same application also comes as an ordinary MSI, for handing round outside the
Store — to try it out before the Store has passed judgement, or if it never does.
Nothing has to be installed beyond the .NET SDK: WiX comes from a NuGet package,
the same way `makeappx` does.
```powershell
# Build it and run it afterwards, to see what a user sees
pwsh -File Packaging\build-installer.ps1 -Install
# Everything a release needs
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
```
The result is `artifacts\installers\CursorLang-<version>-x64.msi`. Everything
travels inside the .msi; there is no cabinet to send alongside it.
It installs for the current user alone, into `%LOCALAPPDATA%\Programs\CursorLang`,
and so asks for no administrator rights and no consent dialog. Uninstalling goes
through Apps in Settings like any other program and takes the startup entry with
it — otherwise Windows would go on listing an application that is no longer there.
Nobody signs the installer, so Windows warns about an unknown publisher and the
user has to insist. Buying a certificate would not silence it at once either:
SmartScreen goes by reputation, and a fresh certificate has none until enough
people have installed the application.
Two things about the WiX project are worth knowing before touching it. It pins
WiX 5 rather than the current 7: from version 6 the toolset asks every build to
accept the Open Source Maintenance Fee licence — free below $10,000 of yearly
revenue, but a decision for a person rather than for a build script. And it turns
off three ICE validation rules: MSI still assumes an installation for the whole
machine, and installing into the user's own profile trips rules that describe
exactly what was intended here.