diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index 752ab25..4c42923 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -2,10 +2,17 @@
# packs the MSIX with the version taken from the tag — three numbers of the tag
# and a zero the Store keeps for itself.
#
+# The package is built twice, because the two places it goes to want different
+# things of it. The Store gets a package with the identity reserved in Partner
+# Center and no signature — Partner Center signs it there. The release gets a
+# package signed here, with SSL.com's certificate the private key of which never
+# leaves their HSM; Windows installs nothing else. The two only differ inside,
+# so the Store one carries a suffix in its name and never leaves the artifacts.
+#
# The same requirements to the runner as in pull-request.yml apply: Windows, the
-# .NET 10 SDK and an interactive desktop session for the tests. makeappx comes
-# with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does
-# not have to be installed.
+# .NET 10 SDK and an interactive desktop session for the tests. makeappx and
+# signtool come with a NuGet package (Packaging\Tools\SdkTools.csproj), so the
+# Windows SDK does not have to be installed.
name: Release
on:
@@ -77,6 +84,25 @@ 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 signing account is asked about before anything is built rather than
+ # at the step that needs it: a release without a signed package is not a
+ # release, and finding that out after the build and the tests costs the
+ # whole run
+ - name: Check the signing credentials
+ env:
+ ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
+ ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
+ ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $missing = @('ESIGNER_USERNAME', 'ESIGNER_PASSWORD', 'ESIGNER_TOTP_SECRET') |
+ Where-Object { -not (Get-Item "Env:$_" -ErrorAction SilentlyContinue).Value }
+
+ if ($missing) {
+ throw "The repository secrets $($missing -join ', ') are not set. They are the SSL.com account the package is signed with; the TOTP secret is the one eSigner hands out for automated signing, not a six-digit code."
+ }
+
- name: Show the toolchain
run: dotnet --info
@@ -97,7 +123,12 @@ jobs:
# The package comes out as Partner Center wants it — the Store puts its own
# signature on it. The identity comes from repository variables and falls
# back to the defaults of the script when a variable is not set.
- - name: Pack the MSIX
+ #
+ # This one is picked up by hand and uploaded to Partner Center, so it goes
+ # no further than the artifacts of the run: attached to the release it
+ # would sit there as a package nobody can install, next to one that
+ # installs — telling the two apart is what the suffix in the name is for
+ - name: Pack the MSIX for the Store
env:
IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }}
PUBLISHER: ${{ vars.MSIX_PUBLISHER }}
@@ -105,7 +136,11 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
- $arguments = @{ Version = '${{ steps.version.outputs.version }}' }
+ $arguments = @{
+ Version = '${{ steps.version.outputs.version }}'
+ PackageSuffix = 'store'
+ OutputPath = 'artifacts/store'
+ }
# An empty variable is left out rather than passed on: the script has
# defaults of its own, and an empty string would wipe them
@@ -121,11 +156,123 @@ jobs:
./Packaging/build-msix.ps1 @arguments
- - name: Keep the packages
+ - name: Keep the Store packages
+ uses: actions/upload-artifact@v4
+ with:
+ name: msix-store-${{ steps.version.outputs.version }}
+ path: artifacts/store/packages/
+ if-no-files-found: error
+
+ # eSigner CKA is a key storage provider: it puts the certificate into the
+ # store of this user and answers signtool's requests for the private key
+ # over SSL.com's API, so the key itself never comes down to the runner.
+ # From signtool's side it looks like a certificate on a token, minus the
+ # token and minus the person who would plug it in.
+ #
+ # The TOTP secret is what stands in for that person: eSigner hands it out
+ # once, for automated signing, and the tool makes the codes out of it
+ # itself
+ - name: Load the signing certificate
+ id: certificate
+ env:
+ CKA_URL: https://github.com/SSLcom/eSignerCKA/releases/download/v1.0.6/SSL.COM-eSigner-CKA_1.0.6.zip
+ ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
+ ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
+ ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $temporary = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
+ $archive = Join-Path $temporary 'eSignerCKA.zip'
+ $unpacked = Join-Path $temporary 'eSignerCKA'
+
+ # Everything of the adapter's own — the installation and the master
+ # key it keeps the account in — lives outside the workspace: the
+ # workspace is what gets packed and uploaded
+ $suite = Join-Path $env:USERPROFILE '.signingsuite'
+ $installation = Join-Path $suite 'eSignerCKA'
+
+ Remove-Item $unpacked -Recurse -Force -ErrorAction SilentlyContinue
+ New-Item -ItemType Directory -Path $suite -Force | Out-Null
+
+ Invoke-WebRequest -Uri $env:CKA_URL -OutFile $archive
+ Expand-Archive -Path $archive -DestinationPath $unpacked -Force
+
+ $installer = Get-ChildItem $unpacked -Recurse -Filter '*.exe' | Select-Object -First 1
+ if (-not $installer) { throw "No installer inside the eSigner CKA archive at '$env:CKA_URL'." }
+
+ # /CURRENTUSER, so that the certificate lands in the store of the user
+ # the build runs as — the same one signtool then looks in
+ & $installer.FullName /CURRENTUSER /VERYSILENT /SUPPRESSMSGBOXES "/DIR=$installation" | Out-Null
+
+ $tool = Join-Path $installation 'eSignerCKATool.exe'
+ if (-not (Test-Path $tool)) { throw "eSigner CKA did not install: '$tool' is not there." }
+
+ & $tool config -mode product -user $env:ESIGNER_USERNAME -pass $env:ESIGNER_PASSWORD -totp $env:ESIGNER_TOTP_SECRET -key (Join-Path $suite 'master.key') -r
+ if ($LASTEXITCODE -ne 0) { throw "eSigner CKA turned down the account: the tool exited with code $LASTEXITCODE." }
+
+ # A run of the same runner could have left a certificate loaded from
+ # another account; unload says nothing when there is nothing to
+ # unload, and its exit code is of no interest for that reason
+ & $tool unload | Out-Null
+
+ & $tool load
+ if ($LASTEXITCODE -ne 0) { throw "eSigner CKA could not load the certificate: the tool exited with code $LASTEXITCODE." }
+
+ # An account may hold more than one certificate — a renewal leaves the
+ # old one behind — and the one that lives longest is the one to sign
+ # with: a signature made by a certificate about to expire is timestamped
+ # and stays good, but the next release would have to be made anyway
+ $certificate = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert |
+ Sort-Object NotAfter -Descending |
+ Select-Object -First 1
+
+ if (-not $certificate) {
+ throw 'eSigner CKA loaded nothing into the certificate store. Does the account hold a code signing certificate — a document signature is a different thing and cannot sign a package.'
+ }
+
+ Write-Host "Signing as $($certificate.Subject), good until $($certificate.NotAfter.ToString('yyyy-MM-dd'))."
+
+ "thumbprint=$($certificate.Thumbprint)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
+ "subject=$($certificate.Subject)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
+
+ # The package of the release carries the identity the app is known by and
+ # the publisher of the certificate — Windows works out the family name of
+ # a package from the two together, so an update replaces the installed
+ # version only while both stay as they were. The names have no suffix:
+ # that is what the app looks for when it checks for an update
+ - name: Pack and sign the MSIX for the release
+ env:
+ IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }}
+ PUBLISHER: ${{ steps.certificate.outputs.subject }}
+ PUBLISHER_DISPLAY_NAME: ${{ vars.MSIX_PUBLISHER_DISPLAY_NAME }}
+ THUMBPRINT: ${{ steps.certificate.outputs.thumbprint }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $arguments = @{
+ Version = '${{ steps.version.outputs.version }}'
+ OutputPath = 'artifacts/release'
+ Publisher = $env:PUBLISHER
+ CertificateThumbprint = $env:THUMBPRINT
+ }
+
+ $variables = @{
+ IdentityName = $env:IDENTITY_NAME
+ PublisherDisplayName = $env:PUBLISHER_DISPLAY_NAME
+ }
+
+ foreach ($name in $variables.Keys) {
+ if ($variables[$name]) { $arguments[$name] = $variables[$name] }
+ }
+
+ ./Packaging/build-msix.ps1 @arguments
+
+ - name: Keep the signed packages
uses: actions/upload-artifact@v4
with:
name: msix-${{ steps.version.outputs.version }}
- path: artifacts/packages/
+ path: artifacts/release/packages/
if-no-files-found: error
# Gitea creates a release of its own for a pushed tag, so the release is
@@ -153,7 +300,9 @@ jobs:
$release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body
}
- foreach ($file in Get-ChildItem artifacts/packages -File) {
+ # Only the signed packages: the Store one stays in the artifacts of
+ # the run, where whoever uploads it to Partner Center picks it up
+ foreach ($file in Get-ChildItem artifacts/release/packages -File) {
# 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 }
diff --git a/CursorLang.Agent/CursorLang.Agent.csproj b/CursorLang.Agent/CursorLang.Agent.csproj
index 6658901..8f2b472 100644
--- a/CursorLang.Agent/CursorLang.Agent.csproj
+++ b/CursorLang.Agent/CursorLang.Agent.csproj
@@ -22,7 +22,7 @@
1.0.0.0
1.0.0.0
CursorLang
- Aleksandr Neychev
+ Aleksandr Neichev
Shows the keyboard layout at the cursor
Copyright (c) 2026
diff --git a/CursorLang.Core/CursorLang.Core.csproj b/CursorLang.Core/CursorLang.Core.csproj
index 74e20bc..c0c40b9 100644
--- a/CursorLang.Core/CursorLang.Core.csproj
+++ b/CursorLang.Core/CursorLang.Core.csproj
@@ -16,7 +16,7 @@
1.0.0.0
1.0.0.0
CursorLang
- Aleksandr Neychev
+ Aleksandr Neichev
Shared part of CursorLang: models, settings, layout tracking, updates
Copyright (c) 2026
diff --git a/CursorLang.Settings/CursorLang.Settings.csproj b/CursorLang.Settings/CursorLang.Settings.csproj
index 409a83c..4f82c24 100644
--- a/CursorLang.Settings/CursorLang.Settings.csproj
+++ b/CursorLang.Settings/CursorLang.Settings.csproj
@@ -24,7 +24,7 @@
1.0.0.0
1.0.0.0
CursorLang
- Aleksandr Neychev
+ Aleksandr Neichev
Settings window of CursorLang
Copyright (c) 2026
diff --git a/Packaging/build-msix.ps1 b/Packaging/build-msix.ps1
index 80a7b19..048f430 100644
--- a/Packaging/build-msix.ps1
+++ b/Packaging/build-msix.ps1
@@ -1,14 +1,17 @@
<#
.SYNOPSIS
- Builds the CursorLang MSIX package for the Microsoft Store.
+ Builds the CursorLang MSIX package.
.DESCRIPTION
- Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a
- NuGet package (Tools\SdkTools.csproj) and the application is built by the
- plain .NET SDK.
+ 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 is handed to Partner Center as it comes out of here — the Store
- is where it gets everything else done to it.
+ 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.
@@ -18,6 +21,17 @@
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
@@ -33,8 +47,14 @@
.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 `
- -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
+ -Publisher "CN=Aleksandr Neichev, O=Aleksandr Neichev, C=KZ" `
+ -CertificateThumbprint A1B2C3D4E5F60718293A4B5C6D7E8F9012345678
#>
[CmdletBinding()]
param(
@@ -42,12 +62,21 @@ param(
[string] $Version = '1.0.0.0',
[string] $IdentityName = 'CursorLang',
- [string] $Publisher = 'CN=Aleksandr Neychev',
- [string] $PublisherDisplayName = 'Aleksandr Neychev',
+ [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
@@ -64,7 +93,14 @@ $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' }
+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'
@@ -76,6 +112,26 @@ 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
@@ -160,11 +216,34 @@ function Get-SdkTool {
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
@@ -189,6 +268,7 @@ foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) {
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) {
@@ -223,7 +303,7 @@ foreach ($architecture in $Architectures) {
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
- $package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix"
+ $package = Join-Path $packagesPath "CursorLang-$Version-$architecture$suffix.msix"
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package)
$built += $package
@@ -242,12 +322,23 @@ if ($built.Count -gt 1) {
New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null
$built | ForEach-Object { Copy-Item $_ -Destination $bundleInput }
- $result = Join-Path $packagesPath "CursorLang-$Version.msixbundle"
+ $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
@@ -261,7 +352,12 @@ Write-Host 'Done.' -ForegroundColor Green
Write-Host " $result"
Write-Host ''
-Write-Host ' This file is uploaded to Partner Center as it is.'
+
+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 ''
diff --git a/README.RU.md b/README.RU.md
index c5bbf09..12fed51 100644
--- a/README.RU.md
+++ b/README.RU.md
@@ -186,8 +186,8 @@ MSIX всегда выполняются в контексте вошедшег
Windows: тот показывает издателя, спрашивает подтверждение и заменяет
установленную версию. Подпись проверяет Windows, поэтому приложенный к выпуску
пакет должен быть подписан — неподписанный установится только на машине в режиме
-разработчика. Работающее приложение до перезапуска продолжает жить на старых
-файлах.
+разработчика; пайплайн подписывает то, что прикладывает. Работающее приложение
+до перезапуска продолжает жить на старых файлах.
У приложения, установленного из Store, раздела обновлений нет вовсе: его
обновляет Store, а пакет со стороны Windows поверх него всё равно не примет.
@@ -263,8 +263,8 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос
на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3`
проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом
-с приложенными пакетами. Номер версии берётся только из тега — тег любого
-другого вида останавливает прогон в самом начале. Версия пакета получается
+с приложенными подписанными пакетами. Номер версии берётся только из тега — тег
+любого другого вида останавливает прогон в самом начале. Версия пакета получается
`1.2.3.0`: Store принимает четыре числа и последнее оставляет себе, так что тег
на него не влияет.
@@ -276,10 +276,29 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
сеансе, пропускают себя: показать окно там негде.
-Пакет, который несёт релиз, загружается в Partner Center как есть. Identity
-берётся из переменных репозитория, а если те не заданы — из значений по
-умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
-и `MSIX_PUBLISHER_DISPLAY_NAME`.
+Пакет собирается дважды: Store и релиз хотят от него разного.
+
+Тот, что для Store, несёт identity, зарезервированную в Partner Center, и не
+подписан — подпись на него ставит сам Partner Center. Дальше артефактов прогона
+он не уходит: его забирают и загружают руками. Что это он, видно по имени —
+`CursorLang-1.2.3.0-store.msixbundle`. Identity берётся из переменных
+репозитория, а если те не заданы — из значений по умолчанию в скрипте:
+`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` и `MSIX_PUBLISHER_DISPLAY_NAME`.
+
+Тот, что приложен к релизу, пайплайн подписывает сертификатом от SSL.com:
+закрытый ключ остаётся в их HSM и на runner не попадает — на нём ставится
+eSigner CKA как поставщик хранилища ключей, и `signtool` спрашивает ключ у него
+так же, как спросил бы у токена. Учётная запись — три секрета репозитория:
+`ESIGNER_USERNAME`, `ESIGNER_PASSWORD` и `ESIGNER_TOTP_SECRET`; последний — тот
+секрет, который eSigner выдаёт для автоматической подписи, а не код с телефона.
+Проверяются они до сборки, а не на шаге подписи: релиз без подписанного пакета —
+не релиз.
+
+`Publisher` у него не `MSIX_PUBLISHER`, а subject этого сертификата, прочитанный
+прямо в прогоне: пакет, называющий кого-то другого, Windows считает подделкой.
+Вместе с identity этот subject задаёт family name пакета, поэтому от релиза к
+релизу оба должны оставаться прежними — иначе обновление встанет рядом со старой
+версией, а не заменит её.
## Сборка пакета MSIX
@@ -297,13 +316,21 @@ powershell -File Packaging\build-msix.ps1 -Architectures x64
# Для Partner Center — identity та, что зарезервирована там
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
- -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
+ -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
```
Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
Center как есть.
+`-CertificateThumbprint` подписывает всё, что собрано, сертификатом с этим
+отпечатком из личного хранилища текущего пользователя; `-Publisher` тогда должен
+называть его subject — скрипт говорит об этом до сборки, а не после. Обычно
+подписывает пайплайн, но то же работает и руками, как только eSigner CKA — или
+сертификат любого другого рода — положит сертификат в хранилище.
+`-PackageSuffix` дописывается в конец имён файлов: сборки для Store и для релиза
+различаются тем, что внутри, и больше ничем.
+
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
MSIX не может установить среду выполнения как зависимость пакета.
@@ -324,5 +351,5 @@ pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
Удалить вручную:
```powershell
-Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName
+Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
```
diff --git a/README.md b/README.md
index a1bff5b..2345f61 100644
--- a/README.md
+++ b/README.md
@@ -181,8 +181,8 @@ The package is downloaded to the temp folder and handed to the Windows app
installer: it shows the publisher, asks for a confirmation and replaces the
installed version. Windows checks the signature, so the package attached to a
release has to be signed — an unsigned one installs nowhere but a machine in
-developer mode. The running app keeps working off the old files until it is
-restarted.
+developer mode; the pipeline signs what it attaches. The running app keeps
+working off the old files until it is restarted.
An app installed from the Store has no updates section at all: the Store
updates it, and a package from the side is something Windows would not accept
@@ -257,7 +257,7 @@ running agent and fails if any part of the WPF renderer is in it.
The pipelines live in `.gitea/workflows` and run on Gitea Actions. A pull
request into `master` is built and tested; a tag of the form `v1.2.3` is built,
-tested, packed into an MSIX and published as a release with the packages
+tested, packed into an MSIX and published as a release with the signed packages
attached. The version is taken from the tag alone — a tag shaped any other way
stops the run right at the start. The package version ends up as `1.2.3.0`: the
Store takes four numbers and keeps the last one for itself, so the tag has no
@@ -271,9 +271,31 @@ need a desktop of their own — the end-to-end ones, and those that ask for the
foreground window or the caret — skip themselves on a runner that lives as a
service in session 0, where there is no desktop to show a window on.
-The package the release carries goes to Partner Center as it is. The identity
-comes from repository variables and falls back to the defaults of the script when
-unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`.
+The package is built twice over, because the Store and a release want different
+things of it.
+
+The Store one carries the identity reserved in Partner Center and no signature —
+Partner Center signs it there. It never leaves the artifacts of the run: someone
+picks it up and uploads it by hand. Its name says which one it is —
+`CursorLang-1.2.3.0-store.msixbundle`. The identity comes from repository
+variables and falls back to the defaults of the script when unset:
+`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`.
+
+The one attached to the release is signed by the pipeline with a certificate
+from SSL.com, whose private key stays in their HSM and never comes down to the
+runner: eSigner CKA is installed on it as a key storage provider, and `signtool`
+asks that for the key the way it would ask a token. The account is three
+repository secrets — `ESIGNER_USERNAME`, `ESIGNER_PASSWORD` and
+`ESIGNER_TOTP_SECRET`; the last one is the secret eSigner hands out for
+automated signing, not a code read off a phone. They are checked before the
+build rather than at the signing step: a release without a signed package is
+not a release.
+
+Its `Publisher` is not `MSIX_PUBLISHER` but the subject of that certificate,
+read off it in the run — Windows takes a package naming anyone else for a
+forgery. Together with the identity that subject decides the family name of the
+package, so both have to stay as they are from release to release, or an update
+installs beside the old version instead of replacing it.
## Building the MSIX package
@@ -291,13 +313,22 @@ powershell -File Packaging\build-msix.ps1 -Architectures x64
# For Partner Center — the identity is the one reserved there
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
- -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
+ -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
```
The result is `artifacts\packages\CursorLang-.msixbundle` covering x64
and arm64; next to it lie the packages of single architectures. Upload the bundle
to Partner Center as it is.
+`-CertificateThumbprint` signs everything the build produces, with the
+certificate of that thumbprint out of the personal store of the current user;
+`-Publisher` then has to name its subject, and the script says so before it
+starts building rather than after. The pipeline is what normally signs, but the
+same works by hand once eSigner CKA — or a certificate of any other kind — has
+put a certificate into the store. `-PackageSuffix` goes at the end of the file
+names: the builds for the Store and for a release differ in what is inside them
+and in nothing else.
+
The app ships with its own copy of .NET: Windows does not include .NET 10, and
MSIX cannot install a runtime as a package dependency.
@@ -318,5 +349,5 @@ the Store answers to the same name and is left alone.
To remove it by hand:
```powershell
-Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName
+Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
```