From 460ce520179d034c6dfe3684ad864cb998ae66b1 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Thu, 13 Aug 2026 02:21:50 +0500 Subject: [PATCH 1/3] modified release pipeline --- .gitea/workflows/release.yml | 165 +++++++++++++++++- CursorLang.Agent/CursorLang.Agent.csproj | 2 +- CursorLang.Core/CursorLang.Core.csproj | 2 +- .../CursorLang.Settings.csproj | 2 +- Packaging/build-msix.ps1 | 122 +++++++++++-- README.RU.md | 47 +++-- README.md | 47 ++++- 7 files changed, 345 insertions(+), 42 deletions(-) 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 ``` -- 2.55.0 From dd0bbdea4268ec2977c834337575717c28ac73b8 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Thu, 13 Aug 2026 14:58:26 +0500 Subject: [PATCH 2/3] removed update --- .gitea/workflows/release.yml | 206 +----------- .../Resources/StringsTests.cs | 11 +- .../Services/GiteaReleaseFeedTests.cs | 304 ------------------ .../Services/UpdateOptionsTests.cs | 32 -- .../Services/UpdateServiceTests.cs | 156 --------- CursorLang.Core/CursorLang.Core.csproj | 2 +- CursorLang.Core/Models/ReleaseInfo.cs | 18 -- CursorLang.Core/Resources/Strings.resx | 44 +-- CursorLang.Core/Resources/Strings.ru.resx | 44 +-- CursorLang.Core/Services/AppVersion.cs | 37 +++ CursorLang.Core/Services/GiteaReleaseFeed.cs | 227 ------------- CursorLang.Core/Services/IReleaseFeed.cs | 21 -- CursorLang.Core/Services/IUpdateService.cs | 40 --- CursorLang.Core/Services/UpdateOptions.cs | 34 -- CursorLang.Core/Services/UpdateService.cs | 224 ------------- CursorLang.Settings.Tests/AppTests.cs | 4 +- .../Infrastructure/SettingsFakes.cs | 11 - .../ViewModels/SettingsViewModelTests.cs | 5 +- .../ViewModels/UpdateViewModelTests.cs | 230 ------------- .../Views/MainWindowTests.cs | 42 +-- CursorLang.Settings/App.xaml.cs | 5 +- .../ViewModels/SettingsViewModel.cs | 25 +- .../ViewModels/UpdateViewModel.cs | 238 -------------- CursorLang.Settings/Views/MainWindow.xaml | 68 +--- CursorLang.Settings/Views/MainWindow.xaml.cs | 22 -- CursorLang.Tests.Shared/FakeHttp.cs | 40 --- CursorLang.Tests.Shared/Fakes.cs | 79 +---- Packaging/build-msix.ps1 | 109 +------ README.RU.md | 117 ++----- README.md | 121 ++----- 30 files changed, 183 insertions(+), 2333 deletions(-) delete mode 100644 CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs delete mode 100644 CursorLang.Core.Tests/Services/UpdateOptionsTests.cs delete mode 100644 CursorLang.Core.Tests/Services/UpdateServiceTests.cs delete mode 100644 CursorLang.Core/Models/ReleaseInfo.cs create mode 100644 CursorLang.Core/Services/AppVersion.cs delete mode 100644 CursorLang.Core/Services/GiteaReleaseFeed.cs delete mode 100644 CursorLang.Core/Services/IReleaseFeed.cs delete mode 100644 CursorLang.Core/Services/IUpdateService.cs delete mode 100644 CursorLang.Core/Services/UpdateOptions.cs delete mode 100644 CursorLang.Core/Services/UpdateService.cs delete mode 100644 CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs delete mode 100644 CursorLang.Settings/ViewModels/UpdateViewModel.cs delete mode 100644 CursorLang.Tests.Shared/FakeHttp.cs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4c42923..23834ff 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,17 +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 package goes to the Store and nowhere else, so it leaves the run as an +# artifact: someone picks it up and uploads it to Partner Center, which puts its +# own signature on it. Nothing is signed here and nothing is attached to the +# release — a publicly trusted code signing certificate is not to be had, and an +# unsigned package would look like something to install and install nowhere. +# Gitea makes the release for the tag itself, and it carries the tag alone. # # 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 and -# signtool come 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 comes +# with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does +# not have to be installed. name: Release on: @@ -84,25 +84,6 @@ 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 @@ -123,12 +104,7 @@ 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. - # - # 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 + - name: Pack the MSIX env: IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }} PUBLISHER: ${{ vars.MSIX_PUBLISHER }} @@ -136,11 +112,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' - $arguments = @{ - Version = '${{ steps.version.outputs.version }}' - PackageSuffix = 'store' - OutputPath = 'artifacts/store' - } + $arguments = @{ Version = '${{ steps.version.outputs.version }}' } # An empty variable is left out rather than passed on: the script has # defaults of its own, and an empty string would wipe them @@ -156,160 +128,10 @@ jobs: ./Packaging/build-msix.ps1 @arguments - - 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 + # The artifact is where the package waits to be uploaded to Partner Center + - name: Keep the package uses: actions/upload-artifact@v4 with: name: msix-${{ steps.version.outputs.version }} - path: artifacts/release/packages/ + path: artifacts/packages/ if-no-files-found: error - - # Gitea creates a release of its own for a pushed tag, so the release is - # looked up first and only made when it is not there - - 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" } - - $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 - } - - # 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 } - 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.Core.Tests/Resources/StringsTests.cs b/CursorLang.Core.Tests/Resources/StringsTests.cs index 6acca5a..afeca45 100644 --- a/CursorLang.Core.Tests/Resources/StringsTests.cs +++ b/CursorLang.Core.Tests/Resources/StringsTests.cs @@ -79,14 +79,15 @@ public sealed class StringsTests } /// - /// The version of an update is put into the string by the app, so the place - /// for it has to be there in both languages. + /// The version is put into the title by the app, so the place for it has to + /// be there in both languages: the title is the only place it is shown, and a + /// translation without the placeholder would quietly drop it. /// [Fact] - public void The_string_about_an_available_update_has_room_for_the_version() + public void The_title_of_the_window_has_room_for_the_version() { - Assert.Contains("{0}", Resources.GetString("UpdateAvailable", English), StringComparison.Ordinal); - Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal); + Assert.Contains("{0}", Resources.GetString("SettingsTitle", English), StringComparison.Ordinal); + Assert.Contains("{0}", Resources.GetString("SettingsTitle", Russian), StringComparison.Ordinal); } public static TheoryData EnumKeys() diff --git a/CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs b/CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs deleted file mode 100644 index 7ffe52b..0000000 --- a/CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System.Net; -using System.Runtime.InteropServices; -using CursorLang.Core.Models; -using CursorLang.Core.Services; -using CursorLang.Tests.Shared; - -namespace CursorLang.Core.Tests.Services; - -/// -/// Reading the release list of Gitea. The answer of the server is not ours to -/// shape, so what matters is what the app makes of it. -/// -public sealed class GiteaReleaseFeedTests -{ - private const string Releases = """ - [ - { - "tag_name": "v1.2.0", - "draft": false, - "prerelease": false, - "html_url": "https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", - "assets": [ - { - "name": "CursorLang-1.2.0.0.msixbundle", - "browser_download_url": "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle", - "size": 4096 - } - ] - } - ] - """; - - [Fact] - public async Task A_release_is_read_whole() - { - ReleaseInfo? release = await Read(Releases); - - Assert.NotNull(release); - Assert.Equal(new Version(1, 2, 0, 0), release.Version); - Assert.Equal("v1.2.0", release.Tag); - Assert.Equal("https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", release.PageUrl?.ToString()); - Assert.Equal("CursorLang-1.2.0.0.msixbundle", release.Package.FileName); - Assert.Equal( - "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle", - release.Package.Url.ToString()); - Assert.Equal(4096, release.Package.Size); - } - - // The API of Gitea lives on the server itself, next to the pages of the - // repository - [Fact] - public async Task The_request_goes_to_the_releases_of_the_project() - { - var handler = FakeHttpHandler.Json(Releases); - var feed = new GiteaReleaseFeed(handler.CreateClient(), Options()); - - await feed.GetLatestAsync(TestContext.Current.CancellationToken); - - Uri asked = Assert.Single(handler.Requests).RequestUri!; - Assert.Equal("git.alrakis.kz", asked.Host); - Assert.StartsWith( - "/api/v1/repos/alrakis/cursor-lang/releases", asked.AbsolutePath, StringComparison.Ordinal); - } - - // A server sitting under a path of its own keeps that path: dropping it - // would send the request to a place that answers nothing - [Fact] - public async Task A_server_behind_a_path_keeps_it() - { - var handler = FakeHttpHandler.Json(Releases); - var feed = new GiteaReleaseFeed( - handler.CreateClient(), - new UpdateOptions { ServiceUri = new Uri("https://host.example.com/gitea"), Project = "team/app" }); - - await feed.GetLatestAsync(TestContext.Current.CancellationToken); - - Uri asked = Assert.Single(handler.Requests).RequestUri!; - Assert.StartsWith("/gitea/api/v1/repos/team/app/releases", asked.AbsolutePath, StringComparison.Ordinal); - } - - // «token» is the scheme of Gitea for keys of access - [Fact] - public void A_closed_repository_gets_the_token_it_asks_for() - { - var feed = new GiteaReleaseFeed(new HttpClient(), Options("secret")); - using var request = new HttpRequestMessage(); - - feed.Authorize(request); - - Assert.Equal("token", request.Headers.Authorization?.Scheme); - Assert.Equal("secret", request.Headers.Authorization?.Parameter); - } - - [Fact] - public void An_open_repository_is_asked_without_a_token() - { - var feed = new GiteaReleaseFeed(new HttpClient(), Options()); - using var request = new HttpRequestMessage(); - - feed.Authorize(request); - - Assert.Null(request.Headers.Authorization); - } - - [Theory] - [InlineData("1.2.3", "1.2.3.0")] - [InlineData("v1.2.3", "1.2.3.0")] - [InlineData("V1.2", "1.2.0.0")] - [InlineData("1.2.3.4", "1.2.3.4")] - public async Task A_version_is_read_out_of_the_tag(string tag, string expected) - { - ReleaseInfo? release = await Read(WithTag(tag)); - - Assert.NotNull(release); - Assert.Equal(Version.Parse(expected), release.Version); - } - - // A pre-release version is not something the app offers by itself: - // such a version is asked for on purpose - [Theory] - [InlineData("v1.2.3-beta")] - [InlineData("nightly")] - [InlineData("release-1")] - public async Task A_tag_that_is_not_a_version_is_passed_over(string tag) - { - Assert.Null(await Read(WithTag(tag))); - } - - [Fact] - public async Task A_draft_and_a_pre_release_are_passed_over() - { - const string releases = """ - [ - { "tag_name": "v3.0.0", "draft": true, "assets": [ - { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }, - { "tag_name": "v2.0.0", "prerelease": true, "assets": [ - { "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] }, - { "tag_name": "v1.0.0", "assets": [ - { "name": "c.msixbundle", "browser_download_url": "https://host/c.msixbundle" } ] } - ] - """; - - ReleaseInfo? release = await Read(releases); - - Assert.NotNull(release); - Assert.Equal(new Version(1, 0, 0, 0), release.Version); - } - - // The order of the releases belongs to the server, the highest number to - // the app: a fix to an older branch can be the freshest release - [Fact] - public async Task The_highest_version_wins_over_the_order_of_the_answer() - { - const string releases = """ - [ - { "tag_name": "v1.0.5", "assets": [ - { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }, - { "tag_name": "v2.0.0", "assets": [ - { "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] } - ] - """; - - ReleaseInfo? release = await Read(releases); - - Assert.NotNull(release); - Assert.Equal(new Version(2, 0, 0, 0), release.Version); - } - - [Fact] - public async Task A_release_without_a_package_is_passed_over() - { - const string releases = """ - [ - { "tag_name": "v2.0.0", "assets": [ - { "name": "notes.txt", "browser_download_url": "https://host/notes.txt" } ] }, - { "tag_name": "v1.0.0", "assets": [ - { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] } - ] - """; - - ReleaseInfo? release = await Read(releases); - - Assert.NotNull(release); - Assert.Equal(new Version(1, 0, 0, 0), release.Version); - } - - // The signature is what Windows checks, but a package offered over an open - // connection is not worth downloading in the first place - [Fact] - public async Task A_package_offered_over_an_open_connection_is_passed_over() - { - const string releases = """ - [ - { "tag_name": "v2.0.0", "assets": [ - { "name": "a.msixbundle", "browser_download_url": "http://host/a.msixbundle" } ] } - ] - """; - - Assert.Null(await Read(releases)); - } - - [Fact] - public async Task A_bundle_wins_over_the_packages_of_single_architectures() - { - const string releases = """ - [ - { "tag_name": "v2.0.0", "assets": [ - { "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" }, - { "name": "CursorLang-2.0.0.0.msixbundle", "browser_download_url": "https://host/all.msixbundle" } ] } - ] - """; - - ReleaseInfo? release = await Read(releases); - - Assert.NotNull(release); - Assert.Equal("https://host/all.msixbundle", release.Package.Url.ToString()); - } - - [Fact] - public async Task Out_of_several_packages_the_one_for_this_machine_is_taken() - { - const string releases = """ - [ - { "tag_name": "v2.0.0", "assets": [ - { "name": "CursorLang-2.0.0.0-arm64.msix", "browser_download_url": "https://host/arm64.msix" }, - { "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" } ] } - ] - """; - - string expected = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 - ? "https://host/arm64.msix" - : "https://host/x64.msix"; - - ReleaseInfo? release = await Read(releases); - - Assert.NotNull(release); - Assert.Equal(expected, release.Package.Url.ToString()); - } - - // Without the architecture in the name there is no telling which package is - // for this machine — unless it is the only one there - [Fact] - public async Task A_package_without_an_architecture_is_taken_only_when_alone() - { - const string alone = """ - [ - { "tag_name": "v2.0.0", "assets": [ - { "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" } ] } - ] - """; - - const string ambiguous = """ - [ - { "tag_name": "v2.0.0", "assets": [ - { "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" }, - { "name": "CursorLang-other.msix", "browser_download_url": "https://host/other.msix" } ] } - ] - """; - - Assert.NotNull(await Read(alone)); - Assert.Null(await Read(ambiguous)); - } - - [Fact] - public async Task An_empty_list_of_releases_means_nothing_to_offer() - { - Assert.Null(await Read("[]")); - } - - // A server answering with something else is no reason to fail - [Fact] - public async Task An_answer_that_is_not_a_list_leaves_the_app_with_nothing() - { - Assert.Null(await Read("""{ "message": "Not Found" }""")); - } - - [Fact] - public async Task A_refusal_of_the_server_is_raised() - { - var handler = FakeHttpHandler.Status(HttpStatusCode.Unauthorized); - var feed = new GiteaReleaseFeed(handler.CreateClient(), Options()); - - await Assert.ThrowsAsync( - () => feed.GetLatestAsync(TestContext.Current.CancellationToken)); - } - - private static string WithTag(string tag) => $$""" - [ - { "tag_name": "{{tag}}", "assets": [ - { "name": "CursorLang.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] } - ] - """; - - private static UpdateOptions Options(string? token = null) => new() - { - ServiceUri = new Uri("https://git.alrakis.kz/"), - Project = "alrakis/cursor-lang", - AccessToken = token, - }; - - private static Task Read(string json) => - new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options()) - .GetLatestAsync(TestContext.Current.CancellationToken); -} diff --git a/CursorLang.Core.Tests/Services/UpdateOptionsTests.cs b/CursorLang.Core.Tests/Services/UpdateOptionsTests.cs deleted file mode 100644 index 218c368..0000000 --- a/CursorLang.Core.Tests/Services/UpdateOptionsTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CursorLang.Core.Services; - -namespace CursorLang.Core.Tests.Services; - -/// -/// Where the app looks for its releases. The values belong to the build, and a -/// wrong one shows only as an update that never arrives. -/// -public sealed class UpdateOptionsTests -{ - [Fact] - public void Out_of_the_box_the_releases_are_looked_for_in_the_repository_of_the_app() - { - var options = new UpdateOptions(); - - Assert.Equal("git.alrakis.kz", options.ServiceUri.Host); - Assert.Equal("alrakis/cursor-lang", options.Project); - } - - [Fact] - public void Another_server_is_taken_as_it_is_given() - { - var options = new UpdateOptions - { - ServiceUri = new Uri("https://git.example.com/"), - Project = "team/app", - }; - - Assert.Equal("git.example.com", options.ServiceUri.Host); - Assert.Equal("team/app", options.Project); - } -} diff --git a/CursorLang.Core.Tests/Services/UpdateServiceTests.cs b/CursorLang.Core.Tests/Services/UpdateServiceTests.cs deleted file mode 100644 index a671c93..0000000 --- a/CursorLang.Core.Tests/Services/UpdateServiceTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Net; -using System.Text; -using CursorLang.Core.Models; -using CursorLang.Core.Services; -using CursorLang.Tests.Shared; - -namespace CursorLang.Core.Tests.Services; - -/// -/// What the app does with a release once it has found one: whether it is newer -/// at all, and what ends up on disk. -/// -public sealed class UpdateServiceTests -{ - [Theory] - [InlineData("1.0.0.0", "1.0.1.0", true)] - [InlineData("1.0.0.0", "2.0.0.0", true)] - [InlineData("1.0.0.0", "1.0.0.0", false)] - [InlineData("1.0.1.0", "1.0.0.0", false)] - public async Task Only_a_higher_version_counts_as_an_update(string current, string found, bool offered) - { - var feed = new FakeReleaseFeed { Release = Release(found) }; - using TempFolder folder = new(); - using UpdateService service = Create(feed, folder, current); - - ReleaseInfo? update = await service.CheckAsync(TestContext.Current.CancellationToken); - - Assert.Equal(offered, update is not null); - } - - [Fact] - public async Task An_empty_repository_leaves_the_app_with_nothing() - { - var feed = new FakeReleaseFeed { Release = null }; - using TempFolder folder = new(); - using UpdateService service = Create(feed, folder); - - Assert.Null(await service.CheckAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task The_package_ends_up_on_disk_whole() - { - byte[] content = Encoding.UTF8.GetBytes(new string('p', 300_000)); - using TempFolder folder = new(); - using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content)); - - string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken); - - Assert.Equal(content, await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken)); - } - - // The name comes from the version, not from the answer: the app creates a - // file with it, and the answer comes from the other side - [Fact] - public async Task The_name_of_the_file_is_built_by_the_app_itself() - { - using TempFolder folder = new(); - using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1, 2, 3])); - - var release = new ReleaseInfo( - new Version(2, 0, 0, 0), - "v2.0.0", - null, - new ReleaseAsset(@"..\..\evil.msixbundle", new Uri("https://host/a"), 3)); - - string path = await service.DownloadAsync(release, null, TestContext.Current.CancellationToken); - - Assert.Equal("CursorLang-2.0.0.0.msixbundle", Path.GetFileName(path)); - Assert.Equal(folder.Path, Path.GetDirectoryName(path)); - } - - [Fact] - public async Task The_download_reports_how_far_it_has_come() - { - byte[] content = new byte[500_000]; - using TempFolder folder = new(); - using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content)); - - var reported = new CollectingProgress(); - await service.DownloadAsync(Release("2.0.0.0"), reported, TestContext.Current.CancellationToken); - - Assert.NotEmpty(reported.Values); - Assert.All(reported.Values, value => Assert.InRange(value, 0, 1)); - Assert.Equal(reported.Values, [.. reported.Values.Order()]); - Assert.Equal(1, reported.Values[^1]); - } - - [Fact] - public async Task A_closed_repository_gets_the_token_with_the_download_too() - { - var feed = new FakeReleaseFeed(); - using TempFolder folder = new(); - using UpdateService service = Create(feed, folder, client: FakeHttpHandler.Bytes([1])); - - await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken); - - Assert.Single(feed.Authorized); - } - - [Fact] - public async Task A_refusal_of_the_hosting_service_leaves_no_package_behind() - { - using TempFolder folder = new(); - using UpdateService service = Create( - new FakeReleaseFeed(), folder, client: FakeHttpHandler.Status(HttpStatusCode.NotFound)); - - await Assert.ThrowsAsync( - () => service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken)); - - Assert.Empty(Directory.GetFiles(folder.Path)); - } - - // A package left from an earlier download takes up room and is of no use - // once it has been installed - [Fact] - public async Task An_older_download_is_cleared_away() - { - using TempFolder folder = new(); - string leftover = folder.File("CursorLang-1.5.0.0.msixbundle"); - await File.WriteAllTextAsync(leftover, "old", TestContext.Current.CancellationToken); - - using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1])); - string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken); - - Assert.False(File.Exists(leftover)); - Assert.True(File.Exists(path)); - } - - /// - /// The reports as the download makes them. Progress<T> would - /// hand them over to another thread, and a test has nowhere to wait for that. - /// - private sealed class CollectingProgress : IProgress - { - internal List Values { get; } = []; - - public void Report(double value) => Values.Add(value); - } - - private static ReleaseInfo Release(string version) => new( - Version.Parse(version), - $"v{version}", - new Uri("https://host/releases/tag"), - new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0)); - - private static UpdateService Create( - IReleaseFeed feed, - TempFolder folder, - string current = "1.0.0.0", - FakeHttpHandler? client = null) => - new(feed, - (client ?? FakeHttpHandler.Bytes([])).CreateClient(), - Version.Parse(current), - folder.Path); -} diff --git a/CursorLang.Core/CursorLang.Core.csproj b/CursorLang.Core/CursorLang.Core.csproj index c0c40b9..a447091 100644 --- a/CursorLang.Core/CursorLang.Core.csproj +++ b/CursorLang.Core/CursorLang.Core.csproj @@ -17,7 +17,7 @@ 1.0.0.0 CursorLang Aleksandr Neichev - Shared part of CursorLang: models, settings, layout tracking, updates + Shared part of CursorLang: models, settings, layout tracking Copyright (c) 2026 diff --git a/CursorLang.Core/Models/ReleaseInfo.cs b/CursorLang.Core/Models/ReleaseInfo.cs deleted file mode 100644 index 20a9fc8..0000000 --- a/CursorLang.Core/Models/ReleaseInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CursorLang.Core.Models; - -/// -/// A file attached to a release. -/// -/// The name the file is saved to disk under. -/// A direct link to the content. -/// The size in bytes; zero when the hosting did not report it. -public sealed record ReleaseAsset(string FileName, Uri Url, long Size); - -/// -/// A release found in the repository. -/// -/// The version parsed from the tag. -/// The tag as is — that is what the interface shows. -/// The release page: the release notes live there too. -/// The MSIX package the application updates itself with. -public sealed record ReleaseInfo(Version Version, string Tag, Uri? PageUrl, ReleaseAsset Package); diff --git a/CursorLang.Core/Resources/Strings.resx b/CursorLang.Core/Resources/Strings.resx index 1e72d35..49378a1 100644 --- a/CursorLang.Core/Resources/Strings.resx +++ b/CursorLang.Core/Resources/Strings.resx @@ -59,7 +59,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - CursorLang — Settings + CursorLang {0} — Settings Settings @@ -211,46 +211,4 @@ Startup for this app is now controlled by Windows: Settings — Apps — Startup. - - Updates - - - Installed version - - - Check for updates - - - Updates have not been checked yet. - - - Checking for updates… - - - The installed version is the latest one. - - - Version {0} is available. - - - Downloading the package… - - - The package has been downloaded. - - - Could not reach the releases. Check the connection and try again. - - - Download - - - Install - - - Release page - - - Windows will show the package and ask to confirm the installation. The new version takes over once the app is restarted. - diff --git a/CursorLang.Core/Resources/Strings.ru.resx b/CursorLang.Core/Resources/Strings.ru.resx index 76064c8..6c9d00f 100644 --- a/CursorLang.Core/Resources/Strings.ru.resx +++ b/CursorLang.Core/Resources/Strings.ru.resx @@ -59,7 +59,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - CursorLang — Настройки + CursorLang {0} — Настройки Настройки @@ -211,46 +211,4 @@ Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка». - - Обновления - - - Установленная версия - - - Проверить обновления - - - Обновления ещё не проверялись. - - - Идёт проверка обновлений… - - - Установлена последняя версия. - - - Доступна версия {0}. - - - Идёт загрузка пакета… - - - Пакет скачан. - - - Не удалось обратиться к выпускам. Проверьте подключение и повторите попытку. - - - Скачать - - - Установить - - - Страница выпуска - - - Windows покажет пакет и попросит подтвердить установку. Новая версия начнёт работать после перезапуска приложения. - diff --git a/CursorLang.Core/Services/AppVersion.cs b/CursorLang.Core/Services/AppVersion.cs new file mode 100644 index 0000000..0e60b32 --- /dev/null +++ b/CursorLang.Core/Services/AppVersion.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using CursorLang.Core.Interop; +using Windows.ApplicationModel; + +namespace CursorLang.Core.Services; + +/// +/// The version of the running application. +/// +/// +/// The same application runs from a package and from a folder, and the two keep +/// their version in different places. The answer is computed once: it cannot +/// change while the process lives. +/// +public static class AppVersion +{ + public static Version Current { get; } = Detect(); + + private static Version Detect() + { + if (PackageIdentityNative.IsPackaged) + { + try + { + PackageVersion version = Package.Current.Id.Version; + return new Version(version.Major, version.Minor, version.Build, version.Revision); + } + catch (Exception e) when (e is COMException or InvalidOperationException) + { + // The package was built without a version in the manifest — the assembly version is left + } + } + + return Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0); + } +} diff --git a/CursorLang.Core/Services/GiteaReleaseFeed.cs b/CursorLang.Core/Services/GiteaReleaseFeed.cs deleted file mode 100644 index 795c386..0000000 --- a/CursorLang.Core/Services/GiteaReleaseFeed.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System.Net.Http.Headers; -using System.Runtime.InteropServices; -using System.Text.Json; -using CursorLang.Core.Models; - -namespace CursorLang.Core.Services; - -/// -/// Gitea releases. -/// -/// -/// The service address is the address of the server itself — "https://git.example.com/": -/// the Gitea API lives on the same host as the repository pages. -/// -internal sealed class GiteaReleaseFeed : IReleaseFeed -{ - /// - /// How many releases to ask the server for. It returns them newest first, but the - /// newest one may turn out to have no package — when the build has not been - /// published yet, for instance — so a small reserve is taken. - /// - private const int PageSize = 10; - - /// - /// A response with the release list is a few kilobytes of text. There is no point - /// waiting longer: the check runs in the background, and a failed one bothers nobody. - /// - private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20); - - private readonly HttpClient _client; - private readonly UpdateOptions _options; - - public GiteaReleaseFeed(HttpClient client, UpdateOptions options) - { - _client = client; - _options = options; - } - - /// - /// How the architecture is spelled in the file names built by - /// build-msix.ps1: CursorLang-1.0.0.0-x64.msix. - /// - private static string ArchitectureName => RuntimeInformation.ProcessArchitecture == Architecture.Arm64 - ? "arm64" - : "x64"; - - public async Task GetLatestAsync(CancellationToken cancellationToken) - { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(RequestTimeout); - - using var request = new HttpRequestMessage(HttpMethod.Get, BuildReleasesUri()); - Authorize(request); - - using HttpResponseMessage response = await _client.SendAsync( - request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); - response.EnsureSuccessStatusCode(); - - await using Stream stream = await response.Content.ReadAsStreamAsync(timeout.Token); - using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: timeout.Token); - - if (document.RootElement.ValueKind != JsonValueKind.Array) - { - return null; - } - - // The order of the releases is up to the server, while what we need is the - // highest version number: a fix released for an old branch may well be the newest one - return document.RootElement.EnumerateArray() - .Select(Read) - .OfType() - .MaxBy(release => release.Version); - } - - public void Authorize(HttpRequestMessage request) - { - if (!string.IsNullOrWhiteSpace(_options.AccessToken)) - { - // "token" is the Gitea scheme of its own for access keys; "Bearer" is not - // understood by every version, while this one has been there since the API appeared - request.Headers.Authorization = new AuthenticationHeaderValue("token", _options.AccessToken); - } - } - - /// - /// Parses a single release. null means the release will not do: a draft, - /// a prerelease or a release without a package. - /// - private static ReleaseInfo? Read(JsonElement release) - { - // A draft is visible only to whoever created it, and the application does not - // offer a prerelease: those are sought out deliberately - if (ReadFlag(release, "draft") || ReadFlag(release, "prerelease")) - { - return null; - } - - Version? version = ParseTag(ReadString(release, "tag_name")); - if (version is null) - { - return null; - } - - if (!release.TryGetProperty("assets", out JsonElement assets) || assets.ValueKind != JsonValueKind.Array) - { - return null; - } - - ReleaseAsset? package = PickPackage(assets.EnumerateArray().Select(ReadAsset).OfType()); - if (package is null) - { - return null; - } - - return new ReleaseInfo( - version, - ReadString(release, "tag_name") ?? version.ToString(), - ReadUri(release, "html_url"), - package); - } - - private static ReleaseAsset? ReadAsset(JsonElement asset) - { - string? name = ReadString(asset, "name"); - Uri? url = ReadUri(asset, "browser_download_url"); - - if (string.IsNullOrWhiteSpace(name) || url is null) - { - return null; - } - - long size = asset.TryGetProperty("size", out JsonElement value) && value.TryGetInt64(out long bytes) - ? bytes - : 0; - - return new ReleaseAsset(name, url, size); - } - - /// - /// The version from a tag. "1.2.3" and "v1.2.3" are understood; a tag with - /// anything besides numbers — "v1.2.3-beta" — counts as a prerelease and is - /// skipped: the application does not offer such versions on its own. - /// - private static Version? ParseTag(string? tag) - { - if (string.IsNullOrWhiteSpace(tag)) - { - return null; - } - - ReadOnlySpan numbers = tag.AsSpan().Trim().TrimStart("vV"); - - foreach (char symbol in numbers) - { - if (!char.IsAsciiDigit(symbol) && symbol != '.') - { - return null; - } - } - - if (!Version.TryParse(numbers, out Version? version)) - { - return null; - } - - // In a tag such as "v1.2" the lower parts are not set at all, yet comparing - // them with the version of the installed package calls for zeros - return new Version( - version.Major, - version.Minor, - Math.Max(version.Build, 0), - Math.Max(version.Revision, 0)); - } - - /// - /// Picks the attached file the application updates itself with. - /// - private static ReleaseAsset? PickPackage(IEnumerable assets) - { - // An unencrypted connection is out right away: Windows will check the package - // signature by itself, but a substituted file is not even worth downloading - ReleaseAsset[] packages = [.. assets.Where(asset => asset.Url.Scheme == Uri.UriSchemeHttps)]; - - ReleaseAsset? bundle = packages.FirstOrDefault( - asset => asset.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase)); - - if (bundle is not null) - { - // A bundle carries both architectures, so there is nothing to choose between - return bundle; - } - - ReleaseAsset[] single = [.. packages.Where( - asset => asset.FileName.EndsWith(".msix", StringComparison.OrdinalIgnoreCase))]; - - ReleaseAsset? matching = single.FirstOrDefault( - asset => asset.FileName.Contains(ArchitectureName, StringComparison.OrdinalIgnoreCase)); - - // A package without an architecture in its name will do only when it is the - // only one: otherwise it is unclear which of them is for this machine - return matching ?? (single.Length == 1 ? single[0] : null); - } - - private static string? ReadString(JsonElement element, string name) => - element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String - ? value.GetString() - : null; - - private static Uri? ReadUri(JsonElement element, string name) => - Uri.TryCreate(ReadString(element, name), UriKind.Absolute, out Uri? uri) ? uri : null; - - private static bool ReadFlag(JsonElement element, string name) => - element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.True; - - /// - /// The address the server returns the release list at. The trailing slash matters: - /// without it Uri drops the last part of the address, and - /// "https://host/gitea" would have turned into "https://host/api/...". - /// - private Uri BuildReleasesUri() - { - string service = _options.ServiceUri.AbsoluteUri; - string path = $"api/v1/repos/{_options.Project.Trim('/')}/releases?limit={PageSize}"; - - return new Uri(service.EndsWith('/') ? service + path : $"{service}/{path}"); - } -} diff --git a/CursorLang.Core/Services/IReleaseFeed.cs b/CursorLang.Core/Services/IReleaseFeed.cs deleted file mode 100644 index 8772216..0000000 --- a/CursorLang.Core/Services/IReleaseFeed.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CursorLang.Core.Models; - -namespace CursorLang.Core.Services; - -/// -/// The release list of the repository. -/// -public interface IReleaseFeed -{ - /// - /// Returns the newest release carrying an MSIX package, or null - /// when there is no suitable release. - /// - Task GetLatestAsync(CancellationToken cancellationToken); - - /// - /// Adds to the request whatever a private repository needs. The package is - /// downloaded not by the list itself, but access to it is closed just the same. - /// - void Authorize(HttpRequestMessage request); -} diff --git a/CursorLang.Core/Services/IUpdateService.cs b/CursorLang.Core/Services/IUpdateService.cs deleted file mode 100644 index 35fcf40..0000000 --- a/CursorLang.Core/Services/IUpdateService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CursorLang.Core.Models; - -namespace CursorLang.Core.Services; - -/// -/// Checking for and installing new versions of the application. -/// -public interface IUpdateService -{ - /// - /// It makes sense for this installation to update itself. - /// - /// - /// An application installed from the Store is updated by the Store itself: - /// offering a package from elsewhere on top of it will not do — Windows would - /// not accept it anyway. - /// - bool IsSupported { get; } - - /// The version of the running application. - Version CurrentVersion { get; } - - /// - /// Looks for a release newer than the installed one. null means the latest - /// version is installed or there is no suitable release in the repository. - /// - Task CheckAsync(CancellationToken cancellationToken); - - /// - /// Downloads the release package and returns the path to it. - /// - /// - /// progress receives the downloaded fraction from 0 to 1. While the file - /// size is unknown — not every hosting reports it — there will be no calls at all. - /// - Task DownloadAsync(ReleaseInfo release, IProgress? progress, CancellationToken cancellationToken); - - /// Hands the downloaded package over to the Windows app installer. - void Install(string packagePath); -} diff --git a/CursorLang.Core/Services/UpdateOptions.cs b/CursorLang.Core/Services/UpdateOptions.cs deleted file mode 100644 index f386214..0000000 --- a/CursorLang.Core/Services/UpdateOptions.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace CursorLang.Core.Services; - -/// -/// Where the application learns about new versions from. -/// -/// -/// These settings belong to the build rather than to the user: the repository is -/// chosen by whoever releases the application, and these values have no business -/// being in settings.json. The defaults point at the repository the -/// application is built from. -/// -public sealed class UpdateOptions -{ - /// - /// The address of the Gitea server. Its API lives on the same host as the - /// repository pages, so this is the same address the repository is opened at - /// in a browser. - /// - public Uri ServiceUri { get; init; } = new("https://git.alrakis.kz/"); - - /// The project: owner/repository. - public string Project { get; init; } = "alrakis/cursor-lang"; - - /// - /// An access token for a private repository. - /// - /// - /// Taken from an environment variable rather than from a file in the repository: - /// a secret that gets into a build gets to everyone who received it as well. - /// A public repository needs no token at all. - /// - public string? AccessToken { get; init; } = - Environment.GetEnvironmentVariable("CURSORLANG_UPDATE_TOKEN"); -} diff --git a/CursorLang.Core/Services/UpdateService.cs b/CursorLang.Core/Services/UpdateService.cs deleted file mode 100644 index 6ec8acd..0000000 --- a/CursorLang.Core/Services/UpdateService.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Reflection; -using System.Runtime.InteropServices; -using CursorLang.Core.Interop; -using CursorLang.Core.Models; -using Windows.ApplicationModel; - -namespace CursorLang.Core.Services; - -/// -/// Learns about new versions from the repository and hands the downloaded package -/// over to the installer. -/// -/// -/// The package is installed by the Windows app installer, not by the application on -/// its own. Through PackageManager the update would go without a single -/// window, but then the application would have to explain both an untrusted signature -/// and a policy ban to the user itself — the installer already knows how to do all -/// that and shows the package publisher before the installation, not after. -/// -public sealed class UpdateService : IUpdateService, IDisposable -{ - /// The package is large and the network can be slow: the buffer is taken with room to spare. - private const int BufferSize = 81920; - - /// The version of the running application — it does not change while it runs. - private static readonly Version Current = DetectCurrentVersion(); - - private readonly IReleaseFeed _feed; - private readonly HttpClient _client; - private readonly bool _ownsClient; - private readonly string _downloadFolder; - - public UpdateService(UpdateOptions options) - { - _client = CreateClient(); - _ownsClient = true; - _downloadFolder = Path.Combine(Path.GetTempPath(), "CursorLang"); - _feed = new GiteaReleaseFeed(_client, options); - - CurrentVersion = Current; - } - - /// - /// Takes the releases, the network and the version explicitly: in tests they are - /// not provided by Windows. - /// - internal UpdateService(IReleaseFeed feed, HttpClient client, Version current, string downloadFolder) - { - _feed = feed; - _client = client; - _ownsClient = false; - _downloadFolder = downloadFolder; - - CurrentVersion = current; - } - - public bool IsSupported { get; } = DetectSupport(); - - public Version CurrentVersion { get; } - - public async Task CheckAsync(CancellationToken cancellationToken) - { - ReleaseInfo? release = await _feed.GetLatestAsync(cancellationToken); - return release is not null && release.Version > CurrentVersion ? release : null; - } - - public async Task DownloadAsync( - ReleaseInfo release, - IProgress? progress, - CancellationToken cancellationToken) - { - string path = Path.Combine(_downloadFolder, BuildFileName(release)); - string partial = path + ".part"; - - Directory.CreateDirectory(_downloadFolder); - RemoveLeftovers(path); - - using var request = new HttpRequestMessage(HttpMethod.Get, release.Package.Url); - _feed.Authorize(request); - - using HttpResponseMessage response = await _client.SendAsync( - request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); - - long total = response.Content.Headers.ContentLength ?? release.Package.Size; - - await using (Stream source = await response.Content.ReadAsStreamAsync(cancellationToken)) - await using (FileStream target = File.Create(partial)) - { - byte[] buffer = new byte[BufferSize]; - long copied = 0; - int reported = -1; - int read; - - while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0) - { - await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - copied += read; - - if (total <= 0) - { - continue; - } - - // The progress bar cannot tell fractions of a percent apart, and - // redrawing on every chunk read would cost more than the download itself - int percent = (int)(copied * 100 / total); - if (percent != reported) - { - reported = percent; - progress?.Report(percent / 100d); - } - } - } - - // A file becomes ready only once downloaded in full: an interrupted download - // must not stay on disk under the package name - File.Move(partial, path, overwrite: true); - return path; - } - - public void Install(string packagePath) => - Process.Start(new ProcessStartInfo(packagePath) { UseShellExecute = true })?.Dispose(); - - public void Dispose() - { - if (_ownsClient) - { - _client.Dispose(); - } - } - - private static HttpClient CreateClient() - { - // The check and the download have different deadlines: seconds are enough for - // the first one, while the second one takes minutes on a slow network. So the - // client has no shared timeout, and every operation allots time for itself - var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(15) }; - var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; - - // The User-Agent shows who came: a request without one may well be taken - // for a robot and rejected by the server - client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CursorLang", Current.ToString())); - - return client; - } - - /// - /// Whether to check for updates at all: a package from the Store gets them from the Store. - /// - private static bool DetectSupport() - { - if (!PackageIdentityNative.IsPackaged) - { - return true; - } - - try - { - return Package.Current.SignatureKind != PackageSignatureKind.Store; - } - catch (Exception e) when (e is COMException or InvalidOperationException) - { - return true; - } - } - - private static Version DetectCurrentVersion() - { - if (PackageIdentityNative.IsPackaged) - { - try - { - // A package has a version of its own — the one from the manifest. That - // is also the one in the release tag, while the assembly version may differ - PackageVersion version = Package.Current.Id.Version; - return new Version(version.Major, version.Minor, version.Build, version.Revision); - } - catch (Exception e) when (e is COMException or InvalidOperationException) - { - // The package was built without a version in the manifest — the assembly version is left - } - } - - return Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0); - } - - /// - /// The file name on disk. Only the extension is taken from the hosting response: - /// the name itself comes from the outside, and a file is created with it. - /// - private static string BuildFileName(ReleaseInfo release) - { - string extension = release.Package.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase) - ? ".msixbundle" - : ".msix"; - - return $"CursorLang-{release.Version}{extension}"; - } - - /// - /// Removes packages downloaded earlier: they take up a noticeable amount of - /// space and are needed only until the installation. - /// - private void RemoveLeftovers(string keep) - { - try - { - foreach (string file in Directory.EnumerateFiles(_downloadFolder)) - { - if (!string.Equals(file, keep, StringComparison.OrdinalIgnoreCase)) - { - File.Delete(file); - } - } - } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) - { - // The file is held by another installer — that does not get in the way of the update - } - } -} diff --git a/CursorLang.Settings.Tests/AppTests.cs b/CursorLang.Settings.Tests/AppTests.cs index 71ee512..172bb2a 100644 --- a/CursorLang.Settings.Tests/AppTests.cs +++ b/CursorLang.Settings.Tests/AppTests.cs @@ -22,10 +22,8 @@ public sealed class AppTests [InlineData(typeof(MainWindowPlacement))] [InlineData(typeof(ILocalizationService))] [InlineData(typeof(IStartupService))] - [InlineData(typeof(IUpdateService))] - [InlineData(typeof(UpdateOptions))] + [InlineData(typeof(Version))] [InlineData(typeof(SettingsViewModel))] - [InlineData(typeof(UpdateViewModel))] [InlineData(typeof(MainWindow))] [InlineData(typeof(AppSettings))] public void Everything_the_window_needs_is_declared_in_the_container(Type service) diff --git a/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs b/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs index e3c653f..f269be2 100644 --- a/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs +++ b/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs @@ -1,19 +1,8 @@ using CursorLang.Core.Models; using CursorLang.Settings.Services; -using CursorLang.Settings.ViewModels; -using CursorLang.Tests.Shared; namespace CursorLang.Settings.Tests.Infrastructure; -/// -/// Parts every test needs but few tests care about. -/// -internal static class Fake -{ - internal static UpdateViewModel Updates() => - new(new FakeUpdateService(), new FakeLocalizationService()); -} - /// /// A theme that paints nothing and only remembers the windows attached to it. /// diff --git a/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs b/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs index fb0dafe..3a23b53 100644 --- a/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs +++ b/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs @@ -285,11 +285,12 @@ public sealed class SettingsViewModelTests private static SettingsViewModel Create( AppSettings? settings = null, ILocalizationService? localization = null, - IStartupService? startup = null) => + IStartupService? startup = null, + Version? version = null) => new(settings ?? new AppSettings(), localization ?? new FakeLocalizationService(), startup ?? new FakeStartupService(), - Fake.Updates()); + version ?? new Version(1, 0, 0, 0)); // The setting travels to Windows without being awaited: the window must not freeze private static async Task WaitForStartupRequests(FakeStartupService startup, int count) diff --git a/CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs b/CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs deleted file mode 100644 index 5566929..0000000 --- a/CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System.IO; -using System.Net.Http; -using CursorLang.Core.Models; -using CursorLang.Core.Services; -using CursorLang.Settings.ViewModels; -using CursorLang.Tests.Shared; - -namespace CursorLang.Settings.Tests.ViewModels; - -/// -/// The updates section of the settings window: what it shows at every step and -/// what it asks of the service behind it. -/// -public sealed class UpdateViewModelTests -{ - // The section has something to say at every moment, the one before the first - // answer included: the status line is never an empty spot in the window - [Fact] - public void Before_the_first_check_the_section_says_so() - { - using UpdateViewModel viewModel = Create(new FakeUpdateService()); - - Assert.Equal(UpdateStatus.Idle, viewModel.Status); - Assert.Equal("en:UpdateNotChecked", viewModel.StatusText); - Assert.False(viewModel.IsDownloadOffered); - Assert.False(viewModel.IsInstallOffered); - Assert.True(viewModel.CanCheck); - } - - [Fact] - public async Task An_update_found_is_offered_for_download() - { - var updates = new FakeUpdateService { Release = Release("2.0.0.0") }; - using UpdateViewModel viewModel = Create(updates); - - await viewModel.CheckCommand.ExecuteAsync(null); - - Assert.Equal(UpdateStatus.Available, viewModel.Status); - Assert.True(viewModel.IsDownloadOffered); - Assert.False(viewModel.IsInstallOffered); - Assert.Equal("https://host/releases/tag", viewModel.ReleaseUrl?.ToString()); - Assert.True(viewModel.IsReleaseLinkShown); - Assert.Equal("en:UpdateAvailable", viewModel.StatusText); - } - - [Fact] - public async Task With_the_latest_version_installed_there_is_nothing_to_offer() - { - using UpdateViewModel viewModel = Create(new FakeUpdateService()); - - await viewModel.CheckCommand.ExecuteAsync(null); - - Assert.Equal(UpdateStatus.UpToDate, viewModel.Status); - Assert.False(viewModel.IsDownloadOffered); - Assert.False(viewModel.IsReleaseLinkShown); - } - - [Fact] - public async Task A_check_by_the_button_says_when_it_did_not_work_out() - { - var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") }; - using UpdateViewModel viewModel = Create(updates); - - await viewModel.CheckCommand.ExecuteAsync(null); - - Assert.Equal(UpdateStatus.Failed, viewModel.Status); - Assert.Equal("en:UpdateFailed", viewModel.StatusText); - } - - // The window is opened by hand, and the answer is what the section is there - // for: a dead network is part of the answer rather than a reason to say nothing - [Fact] - public async Task A_check_when_the_window_opens_says_when_it_did_not_work_out() - { - var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") }; - using UpdateViewModel viewModel = Create(updates); - - await viewModel.StartAsync(); - - Assert.Equal(UpdateStatus.Failed, viewModel.Status); - Assert.Equal("en:UpdateFailed", viewModel.StatusText); - } - - // Nothing is remembered between openings: an answer from yesterday is worth - // less than today's, and the request costs nothing at this rate - [Fact] - public async Task Every_opening_of_the_window_asks_anew() - { - var updates = new FakeUpdateService(); - using UpdateViewModel viewModel = Create(updates); - - await viewModel.StartAsync(); - Assert.Equal(1, updates.CheckCalls); - - await viewModel.StartAsync(); - Assert.Equal(2, updates.CheckCalls); - } - - [Fact] - public async Task A_package_from_Store_is_left_to_Store() - { - var updates = new FakeUpdateService { IsSupported = false, Release = Release("2.0.0.0") }; - using UpdateViewModel viewModel = Create(updates); - - await viewModel.StartAsync(); - await viewModel.CheckCommand.ExecuteAsync(null); - - Assert.False(viewModel.IsSupported); - Assert.Equal(0, updates.CheckCalls); - } - - [Fact] - public async Task A_downloaded_package_is_offered_for_installation() - { - using TempFolder folder = new(); - string package = folder.File("CursorLang-2.0.0.0.msixbundle"); - await File.WriteAllTextAsync(package, "package", TestContext.Current.CancellationToken); - - var updates = new FakeUpdateService { Release = Release("2.0.0.0"), PackagePath = package }; - using UpdateViewModel viewModel = Create(updates); - - await viewModel.CheckCommand.ExecuteAsync(null); - await viewModel.DownloadCommand.ExecuteAsync(null); - - Assert.Equal(UpdateStatus.Ready, viewModel.Status); - Assert.True(viewModel.IsInstallOffered); - Assert.False(viewModel.IsDownloadOffered); - - viewModel.InstallCommand.Execute(null); - - Assert.Equal([package], updates.Installed); - } - - [Fact] - public async Task While_the_package_is_downloading_the_section_shows_it() - { - var updates = new FakeUpdateService - { - Release = Release("2.0.0.0"), - DownloadGate = new TaskCompletionSource(), - }; - - using UpdateViewModel viewModel = Create(updates); - await viewModel.CheckCommand.ExecuteAsync(null); - - Task download = viewModel.DownloadCommand.ExecuteAsync(null); - - Assert.Equal(UpdateStatus.Downloading, viewModel.Status); - Assert.True(viewModel.IsProgressShown); - Assert.True(viewModel.IsBusy); - Assert.False(viewModel.CanCheck); - - updates.DownloadGate.SetResult(); - await download; - } - - [Fact] - public async Task A_download_that_did_not_work_out_is_told_about() - { - var updates = new FakeUpdateService { Release = Release("2.0.0.0") }; - using UpdateViewModel viewModel = Create(updates); - await viewModel.CheckCommand.ExecuteAsync(null); - - updates.Failure = new HttpRequestException("the connection dropped"); - await viewModel.DownloadCommand.ExecuteAsync(null); - - Assert.Equal(UpdateStatus.Failed, viewModel.Status); - } - - // The temp folder is cleared by Windows as it sees fit, and the app has no - // business handing a file that is gone to the installer - [Fact] - public async Task A_package_gone_from_the_disk_is_offered_for_download_again() - { - var updates = new FakeUpdateService - { - Release = Release("2.0.0.0"), - PackagePath = Path.Combine(Path.GetTempPath(), "CursorLang.Tests", "never-written.msixbundle"), - }; - - using UpdateViewModel viewModel = Create(updates); - await viewModel.CheckCommand.ExecuteAsync(null); - await viewModel.DownloadCommand.ExecuteAsync(null); - - viewModel.InstallCommand.Execute(null); - - Assert.Empty(updates.Installed); - Assert.Equal(UpdateStatus.Available, viewModel.Status); - } - - [Fact] - public async Task The_status_is_written_in_the_chosen_language() - { - var localization = new FakeLocalizationService(); - using UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization); - - await viewModel.CheckCommand.ExecuteAsync(null); - Assert.Equal("en:UpdateUpToDate", viewModel.StatusText); - - localization.CurrentLanguage = "ru"; - Assert.Equal("ru:UpdateUpToDate", viewModel.StatusText); - } - - [Fact] - public async Task Closing_unsubscribes_from_the_language() - { - var localization = new FakeLocalizationService(); - UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization); - await viewModel.CheckCommand.ExecuteAsync(null); - - List changed = []; - viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName); - - viewModel.Dispose(); - localization.CurrentLanguage = "ru"; - - Assert.Empty(changed); - } - - private static ReleaseInfo Release(string version) => new( - Version.Parse(version), - $"v{version}", - new Uri("https://host/releases/tag"), - new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0)); - - private static UpdateViewModel Create( - IUpdateService updates, - ILocalizationService? localization = null) => - new(updates, localization ?? new FakeLocalizationService()); -} diff --git a/CursorLang.Settings.Tests/Views/MainWindowTests.cs b/CursorLang.Settings.Tests/Views/MainWindowTests.cs index 5d33cec..8345ca3 100644 --- a/CursorLang.Settings.Tests/Views/MainWindowTests.cs +++ b/CursorLang.Settings.Tests/Views/MainWindowTests.cs @@ -404,39 +404,20 @@ public sealed class MainWindowTests }); } + // The version is nowhere else in the window, so the title has to carry it [Fact] - public void The_updates_are_checked_by_the_button_in_the_window() + public void The_title_of_the_window_shows_the_version() { Sta.Run(() => { - var updates = new FakeUpdateService(); - using UpdateViewModel section = CreateUpdates(updates); - using SettingsViewModel viewModel = CreateViewModel(updates: section); + var localization = new FakeLocalizationService(); + localization.Strings["SettingsTitle"] = "CursorLang {0} — Settings"; - Open(viewModel, window => - { - Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand")); + using SettingsViewModel viewModel = CreateViewModel( + localization: localization, + version: new Version(1, 2, 3, 0)); - Assert.True(check.IsVisible); - check.Command.Execute(null); - - Assert.Equal(1, updates.CheckCalls); - }); - }); - } - - // An app installed from the Store is updated by the Store - [Fact] - public void An_app_that_updates_itself_elsewhere_shows_no_updates_section() - { - Sta.Run(() => - { - using UpdateViewModel section = CreateUpdates(new FakeUpdateService { IsSupported = false }); - using SettingsViewModel viewModel = CreateViewModel(updates: section); - - Open(viewModel, window => - Assert.All(FindButtonsBoundTo(window, "Updates.CheckCommand"), button => - Assert.False(button.IsVisible))); + Open(viewModel, window => Assert.Equal("CursorLang 1.2.3 — Settings", window.Title)); }); } @@ -486,11 +467,11 @@ public sealed class MainWindowTests AppSettings? settings = null, ILocalizationService? localization = null, IStartupService? startup = null, - UpdateViewModel? updates = null) => + Version? version = null) => new(settings ?? new AppSettings(), localization ?? new FakeLocalizationService(), startup ?? new FakeStartupService(), - updates ?? Fake.Updates()); + version ?? new Version(1, 0, 0, 0)); private static void Open(SettingsViewModel viewModel, Action check) => Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), check); @@ -525,9 +506,6 @@ public sealed class MainWindowTests } } - private static UpdateViewModel CreateUpdates(IUpdateService updates) => - new(updates, new FakeLocalizationService()); - private static IEnumerable