diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 23834ff..9dce398 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 + } diff --git a/CursorLang.Agent/CursorLang.Agent.csproj b/CursorLang.Agent/CursorLang.Agent.csproj index 8f2b472..e25e9f8 100644 --- a/CursorLang.Agent/CursorLang.Agent.csproj +++ b/CursorLang.Agent/CursorLang.Agent.csproj @@ -14,10 +14,10 @@ app.manifest ..\CursorLang.Core\Resources\CursorLang.ico AnyCPU - win-x64;win-arm64 + win-x64 true false - true + true 1.0.0 1.0.0.0 1.0.0.0 diff --git a/CursorLang.Core/CursorLang.Core.csproj b/CursorLang.Core/CursorLang.Core.csproj index a447091..c52d5c3 100644 --- a/CursorLang.Core/CursorLang.Core.csproj +++ b/CursorLang.Core/CursorLang.Core.csproj @@ -11,7 +11,7 @@ CursorLang.Core CursorLang.Core AnyCPU - win-x64;win-arm64 + win-x64 1.0.0 1.0.0.0 1.0.0.0 diff --git a/CursorLang.Settings/CursorLang.Settings.csproj b/CursorLang.Settings/CursorLang.Settings.csproj index 4f82c24..9a98efd 100644 --- a/CursorLang.Settings/CursorLang.Settings.csproj +++ b/CursorLang.Settings/CursorLang.Settings.csproj @@ -16,10 +16,10 @@ app.manifest ..\CursorLang.Core\Resources\CursorLang.ico AnyCPU - win-x64;win-arm64 + win-x64 true false - true + true 1.0.0 1.0.0.0 1.0.0.0 diff --git a/Packaging/Installer/Package.wxs b/Packaging/Installer/Package.wxs new file mode 100644 index 0000000..1a512c5 --- /dev/null +++ b/Packaging/Installer/Package.wxs @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packaging/Tools/SdkTools.csproj b/Packaging/Tools/SdkTools.csproj index cf72376..e6690d4 100644 --- a/Packaging/Tools/SdkTools.csproj +++ b/Packaging/Tools/SdkTools.csproj @@ -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. --> diff --git a/Packaging/build-msix.ps1 b/Packaging/build-msix.ps1 index 446e0b4..f8daf26 100644 --- a/Packaging/build-msix.ps1 +++ b/Packaging/build-msix.ps1 @@ -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 '' diff --git a/README.RU.md b/README.RU.md index c688230..ddf543c 100644 --- a/README.RU.md +++ b/README.RU.md @@ -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 по-прежнему исходит из установки на всю машину, а установка в +профиль пользователя нарушает правила, которые описывают ровно то, что здесь и +задумано. diff --git a/README.md b/README.md index 2eb3b5a..9a34915 100644 --- a/README.md +++ b/README.md @@ -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-.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--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--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.