await certificate from ssl.com Reviewed-on: #4 Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
@@ -2,6 +2,13 @@
|
|||||||
# packs the MSIX with the version taken from the tag — three numbers of the tag
|
# packs the MSIX with the version taken from the tag — three numbers of the tag
|
||||||
# and a zero the Store keeps for itself.
|
# and a zero the Store keeps for itself.
|
||||||
#
|
#
|
||||||
|
# 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
|
# 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
|
# .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
|
# with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does
|
||||||
@@ -77,6 +84,10 @@ jobs:
|
|||||||
# reserves the last one, so it carries nothing the tag could tell
|
# 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
|
"version=$($tag.Substring(1)).0" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||||
|
|
||||||
|
# The installer answers to nobody about a fourth number and takes the
|
||||||
|
# tag as it is
|
||||||
|
"plain=$($tag.Substring(1))" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||||
|
|
||||||
- name: Show the toolchain
|
- name: Show the toolchain
|
||||||
run: dotnet --info
|
run: dotnet --info
|
||||||
|
|
||||||
@@ -121,15 +132,32 @@ jobs:
|
|||||||
|
|
||||||
./Packaging/build-msix.ps1 @arguments
|
./Packaging/build-msix.ps1 @arguments
|
||||||
|
|
||||||
- name: Keep the packages
|
# The other half of the release: the same application as an ordinary
|
||||||
|
# installer, for handing round outside the Store. Nobody signs it, so
|
||||||
|
# SmartScreen warns about it — see Packaging\installer.iss
|
||||||
|
- name: Build the installer
|
||||||
|
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
|
||||||
|
|
||||||
|
# The artifact is where the package waits to be uploaded to Partner Center
|
||||||
|
- name: Keep the package
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: msix-${{ steps.version.outputs.version }}
|
name: msix-${{ steps.version.outputs.version }}
|
||||||
path: artifacts/packages/
|
path: artifacts/packages/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# Gitea creates a release of its own for a pushed tag, so the release is
|
# The .wixpdb next to each installer is left out on purpose: it is of use
|
||||||
# looked up first and only made when it is not there
|
# only when something has to be traced back to the WiX source
|
||||||
|
- name: Keep the installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: installer-${{ steps.version.outputs.plain }}
|
||||||
|
path: artifacts/installers/*.msi
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Only the installers go into the release. The MSIX stays in the artifacts
|
||||||
|
# of the run: unsigned, it installs nowhere, and its one destination is
|
||||||
|
# Partner Center
|
||||||
- name: Publish the release
|
- name: Publish the release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -145,6 +173,8 @@ jobs:
|
|||||||
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
|
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
|
||||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||||
|
|
||||||
|
# Gitea makes a release of its own for a pushed tag, so the release is
|
||||||
|
# looked up first and only made when it is not there
|
||||||
$release = $null
|
$release = $null
|
||||||
try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { }
|
try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { }
|
||||||
|
|
||||||
@@ -153,7 +183,7 @@ jobs:
|
|||||||
$release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body
|
$release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($file in Get-ChildItem artifacts/packages -File) {
|
foreach ($file in Get-ChildItem artifacts/installers -File -Filter *.msi) {
|
||||||
# A tag can be pushed again after it was deleted; the old file of
|
# A tag can be pushed again after it was deleted; the old file of
|
||||||
# the same name is dropped, otherwise the upload is refused
|
# the same name is dropped, otherwise the upload is refused
|
||||||
$existing = $release.assets | Where-Object { $_.name -eq $file.Name }
|
$existing = $release.assets | Where-Object { $_.name -eq $file.Name }
|
||||||
|
|||||||
@@ -14,15 +14,15 @@
|
|||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
||||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||||
<PublishTrimmed>false</PublishTrimmed>
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
|
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
<Product>CursorLang</Product>
|
<Product>CursorLang</Product>
|
||||||
<Company>Aleksandr Neychev</Company>
|
<Company>Aleksandr Neichev</Company>
|
||||||
<Description>Shows the keyboard layout at the cursor</Description>
|
<Description>Shows the keyboard layout at the cursor</Description>
|
||||||
<Copyright>Copyright (c) 2026</Copyright>
|
<Copyright>Copyright (c) 2026</Copyright>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -79,14 +79,15 @@ public sealed class StringsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The version of an update is put into the string by the app, so the place
|
/// The version is put into the title by the app, so the place for it has to
|
||||||
/// for it has to be there in both languages.
|
/// be there in both languages: the title is the only place it is shown, and a
|
||||||
|
/// translation without the placeholder would quietly drop it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[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("SettingsTitle", English), StringComparison.Ordinal);
|
||||||
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal);
|
Assert.Contains("{0}", Resources.GetString("SettingsTitle", Russian), StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TheoryData<string> EnumKeys()
|
public static TheoryData<string> EnumKeys()
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<HttpRequestException>(
|
|
||||||
() => 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<ReleaseInfo?> Read(string json) =>
|
|
||||||
new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options())
|
|
||||||
.GetLatestAsync(TestContext.Current.CancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using CursorLang.Core.Services;
|
|
||||||
|
|
||||||
namespace CursorLang.Core.Tests.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// What the app does with a release once it has found one: whether it is newer
|
|
||||||
/// at all, and what ends up on disk.
|
|
||||||
/// </summary>
|
|
||||||
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<HttpRequestException>(
|
|
||||||
() => 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The reports as the download makes them. <c>Progress<T></c> would
|
|
||||||
/// hand them over to another thread, and a test has nowhere to wait for that.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class CollectingProgress : IProgress<double>
|
|
||||||
{
|
|
||||||
internal List<double> 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);
|
|
||||||
}
|
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
<RootNamespace>CursorLang.Core</RootNamespace>
|
<RootNamespace>CursorLang.Core</RootNamespace>
|
||||||
<AssemblyName>CursorLang.Core</AssemblyName>
|
<AssemblyName>CursorLang.Core</AssemblyName>
|
||||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
<Product>CursorLang</Product>
|
<Product>CursorLang</Product>
|
||||||
<Company>Aleksandr Neychev</Company>
|
<Company>Aleksandr Neichev</Company>
|
||||||
<Description>Shared part of CursorLang: models, settings, layout tracking, updates</Description>
|
<Description>Shared part of CursorLang: models, settings, layout tracking</Description>
|
||||||
<Copyright>Copyright (c) 2026</Copyright>
|
<Copyright>Copyright (c) 2026</Copyright>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace CursorLang.Core.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A file attached to a release.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="FileName">The name the file is saved to disk under.</param>
|
|
||||||
/// <param name="Url">A direct link to the content.</param>
|
|
||||||
/// <param name="Size">The size in bytes; zero when the hosting did not report it.</param>
|
|
||||||
public sealed record ReleaseAsset(string FileName, Uri Url, long Size);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A release found in the repository.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Version">The version parsed from the tag.</param>
|
|
||||||
/// <param name="Tag">The tag as is — that is what the interface shows.</param>
|
|
||||||
/// <param name="PageUrl">The release page: the release notes live there too.</param>
|
|
||||||
/// <param name="Package">The MSIX package the application updates itself with.</param>
|
|
||||||
public sealed record ReleaseInfo(Version Version, string Tag, Uri? PageUrl, ReleaseAsset Package);
|
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</resheader>
|
||||||
<data name="SettingsTitle" xml:space="preserve">
|
<data name="SettingsTitle" xml:space="preserve">
|
||||||
<value>CursorLang — Settings</value>
|
<value>CursorLang {0} — Settings</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="TrayMenuSettings" xml:space="preserve">
|
<data name="TrayMenuSettings" xml:space="preserve">
|
||||||
<value>Settings</value>
|
<value>Settings</value>
|
||||||
@@ -211,46 +211,4 @@
|
|||||||
<data name="StartupLockedHint" xml:space="preserve">
|
<data name="StartupLockedHint" xml:space="preserve">
|
||||||
<value>Startup for this app is now controlled by Windows: Settings — Apps — Startup.</value>
|
<value>Startup for this app is now controlled by Windows: Settings — Apps — Startup.</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="SectionUpdates" xml:space="preserve">
|
|
||||||
<value>Updates</value>
|
|
||||||
</data>
|
|
||||||
<data name="CurrentVersionLabel" xml:space="preserve">
|
|
||||||
<value>Installed version</value>
|
|
||||||
</data>
|
|
||||||
<data name="CheckUpdatesButton" xml:space="preserve">
|
|
||||||
<value>Check for updates</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateNotChecked" xml:space="preserve">
|
|
||||||
<value>Updates have not been checked yet.</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateChecking" xml:space="preserve">
|
|
||||||
<value>Checking for updates…</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateUpToDate" xml:space="preserve">
|
|
||||||
<value>The installed version is the latest one.</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateAvailable" xml:space="preserve">
|
|
||||||
<value>Version {0} is available.</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateDownloading" xml:space="preserve">
|
|
||||||
<value>Downloading the package…</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateReady" xml:space="preserve">
|
|
||||||
<value>The package has been downloaded.</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateFailed" xml:space="preserve">
|
|
||||||
<value>Could not reach the releases. Check the connection and try again.</value>
|
|
||||||
</data>
|
|
||||||
<data name="DownloadUpdateButton" xml:space="preserve">
|
|
||||||
<value>Download</value>
|
|
||||||
</data>
|
|
||||||
<data name="InstallUpdateButton" xml:space="preserve">
|
|
||||||
<value>Install</value>
|
|
||||||
</data>
|
|
||||||
<data name="ReleasePageLink" xml:space="preserve">
|
|
||||||
<value>Release page</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateInstallHint" xml:space="preserve">
|
|
||||||
<value>Windows will show the package and ask to confirm the installation. The new version takes over once the app is restarted.</value>
|
|
||||||
</data>
|
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</resheader>
|
||||||
<data name="SettingsTitle" xml:space="preserve">
|
<data name="SettingsTitle" xml:space="preserve">
|
||||||
<value>CursorLang — Настройки</value>
|
<value>CursorLang {0} — Настройки</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="TrayMenuSettings" xml:space="preserve">
|
<data name="TrayMenuSettings" xml:space="preserve">
|
||||||
<value>Настройки</value>
|
<value>Настройки</value>
|
||||||
@@ -211,46 +211,4 @@
|
|||||||
<data name="StartupLockedHint" xml:space="preserve">
|
<data name="StartupLockedHint" xml:space="preserve">
|
||||||
<value>Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка».</value>
|
<value>Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка».</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="SectionUpdates" xml:space="preserve">
|
|
||||||
<value>Обновления</value>
|
|
||||||
</data>
|
|
||||||
<data name="CurrentVersionLabel" xml:space="preserve">
|
|
||||||
<value>Установленная версия</value>
|
|
||||||
</data>
|
|
||||||
<data name="CheckUpdatesButton" xml:space="preserve">
|
|
||||||
<value>Проверить обновления</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateNotChecked" xml:space="preserve">
|
|
||||||
<value>Обновления ещё не проверялись.</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateChecking" xml:space="preserve">
|
|
||||||
<value>Идёт проверка обновлений…</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateUpToDate" xml:space="preserve">
|
|
||||||
<value>Установлена последняя версия.</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateAvailable" xml:space="preserve">
|
|
||||||
<value>Доступна версия {0}.</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateDownloading" xml:space="preserve">
|
|
||||||
<value>Идёт загрузка пакета…</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateReady" xml:space="preserve">
|
|
||||||
<value>Пакет скачан.</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateFailed" xml:space="preserve">
|
|
||||||
<value>Не удалось обратиться к выпускам. Проверьте подключение и повторите попытку.</value>
|
|
||||||
</data>
|
|
||||||
<data name="DownloadUpdateButton" xml:space="preserve">
|
|
||||||
<value>Скачать</value>
|
|
||||||
</data>
|
|
||||||
<data name="InstallUpdateButton" xml:space="preserve">
|
|
||||||
<value>Установить</value>
|
|
||||||
</data>
|
|
||||||
<data name="ReleasePageLink" xml:space="preserve">
|
|
||||||
<value>Страница выпуска</value>
|
|
||||||
</data>
|
|
||||||
<data name="UpdateInstallHint" xml:space="preserve">
|
|
||||||
<value>Windows покажет пакет и попросит подтвердить установку. Новая версия начнёт работать после перезапуска приложения.</value>
|
|
||||||
</data>
|
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CursorLang.Core.Interop;
|
||||||
|
using Windows.ApplicationModel;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The version of the running application.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gitea releases.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 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.
|
|
||||||
/// </remarks>
|
|
||||||
internal sealed class GiteaReleaseFeed : IReleaseFeed
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private const int PageSize = 10;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How the architecture is spelled in the file names built by
|
|
||||||
/// <c>build-msix.ps1</c>: <c>CursorLang-1.0.0.0-x64.msix</c>.
|
|
||||||
/// </summary>
|
|
||||||
private static string ArchitectureName => RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
|
||||||
? "arm64"
|
|
||||||
: "x64";
|
|
||||||
|
|
||||||
public async Task<ReleaseInfo?> 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<ReleaseInfo>()
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a single release. <c>null</c> means the release will not do: a draft,
|
|
||||||
/// a prerelease or a release without a package.
|
|
||||||
/// </summary>
|
|
||||||
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<ReleaseAsset>());
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static Version? ParseTag(string? tag)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(tag))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ReadOnlySpan<char> 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Picks the attached file the application updates itself with.
|
|
||||||
/// </summary>
|
|
||||||
private static ReleaseAsset? PickPackage(IEnumerable<ReleaseAsset> 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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The address the server returns the release list at. The trailing slash matters:
|
|
||||||
/// without it <c>Uri</c> drops the last part of the address, and
|
|
||||||
/// "https://host/gitea" would have turned into "https://host/api/...".
|
|
||||||
/// </summary>
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using CursorLang.Core.Models;
|
|
||||||
|
|
||||||
namespace CursorLang.Core.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The release list of the repository.
|
|
||||||
/// </summary>
|
|
||||||
public interface IReleaseFeed
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the newest release carrying an MSIX package, or <c>null</c>
|
|
||||||
/// when there is no suitable release.
|
|
||||||
/// </summary>
|
|
||||||
Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
void Authorize(HttpRequestMessage request);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using CursorLang.Core.Models;
|
|
||||||
|
|
||||||
namespace CursorLang.Core.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checking for and installing new versions of the application.
|
|
||||||
/// </summary>
|
|
||||||
public interface IUpdateService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// It makes sense for this installation to update itself.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 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.
|
|
||||||
/// </remarks>
|
|
||||||
bool IsSupported { get; }
|
|
||||||
|
|
||||||
/// <summary>The version of the running application.</summary>
|
|
||||||
Version CurrentVersion { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Looks for a release newer than the installed one. <c>null</c> means the latest
|
|
||||||
/// version is installed or there is no suitable release in the repository.
|
|
||||||
/// </summary>
|
|
||||||
Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Downloads the release package and returns the path to it.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <c>progress</c> 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.
|
|
||||||
/// </remarks>
|
|
||||||
Task<string> DownloadAsync(ReleaseInfo release, IProgress<double>? progress, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Hands the downloaded package over to the Windows app installer.</summary>
|
|
||||||
void Install(string packagePath);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
namespace CursorLang.Core.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Where the application learns about new versions from.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 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 <c>settings.json</c>. The defaults point at the repository the
|
|
||||||
/// application is built from.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class UpdateOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public Uri ServiceUri { get; init; } = new("https://git.alrakis.kz/");
|
|
||||||
|
|
||||||
/// <summary>The project: <c>owner/repository</c>.</summary>
|
|
||||||
public string Project { get; init; } = "alrakis/cursor-lang";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An access token for a private repository.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 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.
|
|
||||||
/// </remarks>
|
|
||||||
public string? AccessToken { get; init; } =
|
|
||||||
Environment.GetEnvironmentVariable("CURSORLANG_UPDATE_TOKEN");
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Learns about new versions from the repository and hands the downloaded package
|
|
||||||
/// over to the installer.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The package is installed by the Windows app installer, not by the application on
|
|
||||||
/// its own. Through <c>PackageManager</c> 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.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class UpdateService : IUpdateService, IDisposable
|
|
||||||
{
|
|
||||||
/// <summary>The package is large and the network can be slow: the buffer is taken with room to spare.</summary>
|
|
||||||
private const int BufferSize = 81920;
|
|
||||||
|
|
||||||
/// <summary>The version of the running application — it does not change while it runs.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Takes the releases, the network and the version explicitly: in tests they are
|
|
||||||
/// not provided by Windows.
|
|
||||||
/// </summary>
|
|
||||||
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<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
ReleaseInfo? release = await _feed.GetLatestAsync(cancellationToken);
|
|
||||||
return release is not null && release.Version > CurrentVersion ? release : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> DownloadAsync(
|
|
||||||
ReleaseInfo release,
|
|
||||||
IProgress<double>? 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to check for updates at all: a package from the Store gets them from the Store.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static string BuildFileName(ReleaseInfo release)
|
|
||||||
{
|
|
||||||
string extension = release.Package.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? ".msixbundle"
|
|
||||||
: ".msix";
|
|
||||||
|
|
||||||
return $"CursorLang-{release.Version}{extension}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes packages downloaded earlier: they take up a noticeable amount of
|
|
||||||
/// space and are needed only until the installation.
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,10 +22,8 @@ public sealed class AppTests
|
|||||||
[InlineData(typeof(MainWindowPlacement))]
|
[InlineData(typeof(MainWindowPlacement))]
|
||||||
[InlineData(typeof(ILocalizationService))]
|
[InlineData(typeof(ILocalizationService))]
|
||||||
[InlineData(typeof(IStartupService))]
|
[InlineData(typeof(IStartupService))]
|
||||||
[InlineData(typeof(IUpdateService))]
|
[InlineData(typeof(Version))]
|
||||||
[InlineData(typeof(UpdateOptions))]
|
|
||||||
[InlineData(typeof(SettingsViewModel))]
|
[InlineData(typeof(SettingsViewModel))]
|
||||||
[InlineData(typeof(UpdateViewModel))]
|
|
||||||
[InlineData(typeof(MainWindow))]
|
[InlineData(typeof(MainWindow))]
|
||||||
[InlineData(typeof(AppSettings))]
|
[InlineData(typeof(AppSettings))]
|
||||||
public void Everything_the_window_needs_is_declared_in_the_container(Type service)
|
public void Everything_the_window_needs_is_declared_in_the_container(Type service)
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
using CursorLang.Core.Models;
|
using CursorLang.Core.Models;
|
||||||
using CursorLang.Settings.Services;
|
using CursorLang.Settings.Services;
|
||||||
using CursorLang.Settings.ViewModels;
|
|
||||||
using CursorLang.Tests.Shared;
|
|
||||||
|
|
||||||
namespace CursorLang.Settings.Tests.Infrastructure;
|
namespace CursorLang.Settings.Tests.Infrastructure;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parts every test needs but few tests care about.
|
|
||||||
/// </summary>
|
|
||||||
internal static class Fake
|
|
||||||
{
|
|
||||||
internal static UpdateViewModel Updates() =>
|
|
||||||
new(new FakeUpdateService(), new FakeLocalizationService());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A theme that paints nothing and only remembers the windows attached to it.
|
/// A theme that paints nothing and only remembers the windows attached to it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -285,11 +285,12 @@ public sealed class SettingsViewModelTests
|
|||||||
private static SettingsViewModel Create(
|
private static SettingsViewModel Create(
|
||||||
AppSettings? settings = null,
|
AppSettings? settings = null,
|
||||||
ILocalizationService? localization = null,
|
ILocalizationService? localization = null,
|
||||||
IStartupService? startup = null) =>
|
IStartupService? startup = null,
|
||||||
|
Version? version = null) =>
|
||||||
new(settings ?? new AppSettings(),
|
new(settings ?? new AppSettings(),
|
||||||
localization ?? new FakeLocalizationService(),
|
localization ?? new FakeLocalizationService(),
|
||||||
startup ?? new FakeStartupService(),
|
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
|
// The setting travels to Windows without being awaited: the window must not freeze
|
||||||
private static async Task WaitForStartupRequests(FakeStartupService startup, int count)
|
private static async Task WaitForStartupRequests(FakeStartupService startup, int count)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The updates section of the settings window: what it shows at every step and
|
|
||||||
/// what it asks of the service behind it.
|
|
||||||
/// </summary>
|
|
||||||
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<string?> 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());
|
|
||||||
}
|
|
||||||
@@ -404,39 +404,20 @@ public sealed class MainWindowTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The version is nowhere else in the window, so the title has to carry it
|
||||||
[Fact]
|
[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(() =>
|
Sta.Run(() =>
|
||||||
{
|
{
|
||||||
var updates = new FakeUpdateService();
|
var localization = new FakeLocalizationService();
|
||||||
using UpdateViewModel section = CreateUpdates(updates);
|
localization.Strings["SettingsTitle"] = "CursorLang {0} — Settings";
|
||||||
using SettingsViewModel viewModel = CreateViewModel(updates: section);
|
|
||||||
|
|
||||||
Open(viewModel, window =>
|
using SettingsViewModel viewModel = CreateViewModel(
|
||||||
{
|
localization: localization,
|
||||||
Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand"));
|
version: new Version(1, 2, 3, 0));
|
||||||
|
|
||||||
Assert.True(check.IsVisible);
|
Open(viewModel, window => Assert.Equal("CursorLang 1.2.3 — Settings", window.Title));
|
||||||
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)));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,11 +467,11 @@ public sealed class MainWindowTests
|
|||||||
AppSettings? settings = null,
|
AppSettings? settings = null,
|
||||||
ILocalizationService? localization = null,
|
ILocalizationService? localization = null,
|
||||||
IStartupService? startup = null,
|
IStartupService? startup = null,
|
||||||
UpdateViewModel? updates = null) =>
|
Version? version = null) =>
|
||||||
new(settings ?? new AppSettings(),
|
new(settings ?? new AppSettings(),
|
||||||
localization ?? new FakeLocalizationService(),
|
localization ?? new FakeLocalizationService(),
|
||||||
startup ?? new FakeStartupService(),
|
startup ?? new FakeStartupService(),
|
||||||
updates ?? Fake.Updates());
|
version ?? new Version(1, 0, 0, 0));
|
||||||
|
|
||||||
private static void Open(SettingsViewModel viewModel, Action<MainWindow> check) =>
|
private static void Open(SettingsViewModel viewModel, Action<MainWindow> check) =>
|
||||||
Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), 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<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
|
private static IEnumerable<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
|
||||||
FindAll<Button>(root).Where(button =>
|
FindAll<Button>(root).Where(button =>
|
||||||
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
|
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ public partial class App : Application
|
|||||||
MainWindow.Show();
|
MainWindow.Show();
|
||||||
|
|
||||||
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
|
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
|
||||||
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnExit(ExitEventArgs e)
|
protected override void OnExit(ExitEventArgs e)
|
||||||
@@ -73,7 +72,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
internal static void ConfigureServices(IServiceCollection services)
|
internal static void ConfigureServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton(new UpdateOptions());
|
services.AddSingleton(AppVersion.Current);
|
||||||
|
|
||||||
services.AddSingleton<SettingsService>();
|
services.AddSingleton<SettingsService>();
|
||||||
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
|
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
|
||||||
@@ -85,9 +84,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||||
services.AddSingleton<IStartupService, StartupService>();
|
services.AddSingleton<IStartupService, StartupService>();
|
||||||
services.AddSingleton<IUpdateService, UpdateService>();
|
|
||||||
|
|
||||||
services.AddSingleton<UpdateViewModel>();
|
|
||||||
services.AddSingleton<SettingsViewModel>();
|
services.AddSingleton<SettingsViewModel>();
|
||||||
|
|
||||||
services.AddSingleton<MainWindow>();
|
services.AddSingleton<MainWindow>();
|
||||||
|
|||||||
@@ -16,15 +16,15 @@
|
|||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
||||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||||
<PublishTrimmed>false</PublishTrimmed>
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
|
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
<Product>CursorLang</Product>
|
<Product>CursorLang</Product>
|
||||||
<Company>Aleksandr Neychev</Company>
|
<Company>Aleksandr Neichev</Company>
|
||||||
<Description>Settings window of CursorLang</Description>
|
<Description>Settings window of CursorLang</Description>
|
||||||
<Copyright>Copyright (c) 2026</Copyright>
|
<Copyright>Copyright (c) 2026</Copyright>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
|||||||
];
|
];
|
||||||
|
|
||||||
private readonly IStartupService _startup;
|
private readonly IStartupService _startup;
|
||||||
|
private readonly Version _version;
|
||||||
|
|
||||||
private StartupState _startupState = StartupState.Unavailable;
|
private StartupState _startupState = StartupState.Unavailable;
|
||||||
|
|
||||||
@@ -72,12 +73,12 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
|||||||
AppSettings settings,
|
AppSettings settings,
|
||||||
ILocalizationService localization,
|
ILocalizationService localization,
|
||||||
IStartupService startup,
|
IStartupService startup,
|
||||||
UpdateViewModel updates)
|
Version version)
|
||||||
{
|
{
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
Localization = localization;
|
Localization = localization;
|
||||||
Updates = updates;
|
|
||||||
_startup = startup;
|
_startup = startup;
|
||||||
|
_version = version;
|
||||||
|
|
||||||
// The interface language is a setting like any other and is stored in the same place
|
// The interface language is a setting like any other and is stored in the same place
|
||||||
Localization.CurrentLanguage = settings.Language;
|
Localization.CurrentLanguage = settings.Language;
|
||||||
@@ -95,8 +96,23 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
public ILocalizationService Localization { get; }
|
public ILocalizationService Localization { get; }
|
||||||
|
|
||||||
/// <summary>The updates section: it has a state and commands of its own.</summary>
|
/// <summary>
|
||||||
public UpdateViewModel Updates { get; }
|
/// The title of the window, with the version in it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The version is nowhere else in the interface: a package installed from the
|
||||||
|
/// Store is updated by the Store, so the window has nothing to say about
|
||||||
|
/// updates — but which version is running is still worth knowing, if only to
|
||||||
|
/// name it in a bug report.
|
||||||
|
///
|
||||||
|
/// Three numbers, not four: the fourth one is the revision the Store keeps
|
||||||
|
/// for itself, and it is a zero in every package we build. The numbers are
|
||||||
|
/// put together by hand rather than by <c>ToString(3)</c>, which throws on a
|
||||||
|
/// version that has fewer of them than asked for.
|
||||||
|
/// </remarks>
|
||||||
|
public string Title => string.Format(
|
||||||
|
Localization["SettingsTitle"],
|
||||||
|
$"{_version.Major}.{_version.Minor}.{Math.Max(_version.Build, 0)}");
|
||||||
|
|
||||||
public IReadOnlyList<Color> BackgroundPalette { get; } = Palette;
|
public IReadOnlyList<Color> BackgroundPalette { get; } = Palette;
|
||||||
|
|
||||||
@@ -188,6 +204,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(Title));
|
||||||
Translate(Themes);
|
Translate(Themes);
|
||||||
Translate(PlacementModes);
|
Translate(PlacementModes);
|
||||||
Translate(AnchorSides);
|
Translate(AnchorSides);
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Windows.Data;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using CursorLang.Core.Models;
|
|
||||||
using CursorLang.Core.Services;
|
|
||||||
|
|
||||||
namespace CursorLang.Settings.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The updates section of the settings window.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The section always says where things stand: a check flies off the moment the window
|
|
||||||
/// appears, and its outcome — including an unreachable network — stays written in the
|
|
||||||
/// status line. Nothing here is silent, and nothing disappears: a user who opened the
|
|
||||||
/// window is told about updates whether they came looking for them or not.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed partial class UpdateViewModel : ObservableObject, IDisposable
|
|
||||||
{
|
|
||||||
private readonly IUpdateService _updates;
|
|
||||||
private readonly ILocalizationService _localization;
|
|
||||||
|
|
||||||
private CancellationTokenSource? _work;
|
|
||||||
private ReleaseInfo? _release;
|
|
||||||
private string? _packagePath;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(StatusText))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(CanCheck))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsDownloadOffered))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsInstallOffered))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsProgressShown))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsReleaseLinkShown))]
|
|
||||||
private UpdateStatus _status = UpdateStatus.Idle;
|
|
||||||
|
|
||||||
/// <summary>The downloaded fraction: from 0 to 1.</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
|
|
||||||
private double _progress;
|
|
||||||
|
|
||||||
public UpdateViewModel(IUpdateService updates, ILocalizationService localization)
|
|
||||||
{
|
|
||||||
_updates = updates;
|
|
||||||
_localization = localization;
|
|
||||||
|
|
||||||
_localization.PropertyChanged += OnLocalizationChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Whether to show the updates section at all.</summary>
|
|
||||||
public bool IsSupported => _updates.IsSupported;
|
|
||||||
|
|
||||||
public string CurrentVersion => _updates.CurrentVersion.ToString();
|
|
||||||
|
|
||||||
/// <summary>A request or a download is in flight — the buttons freeze for that time.</summary>
|
|
||||||
public bool IsBusy => Status is UpdateStatus.Checking or UpdateStatus.Downloading;
|
|
||||||
|
|
||||||
public bool CanCheck => !IsBusy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The package size is unknown, and the bar shows only the fact of the download.
|
|
||||||
/// Its start looks the same until the first report arrives.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsProgressUnknown => Status == UpdateStatus.Downloading && Progress <= 0;
|
|
||||||
|
|
||||||
public bool IsDownloadOffered => Status == UpdateStatus.Available;
|
|
||||||
|
|
||||||
public bool IsInstallOffered => Status == UpdateStatus.Ready;
|
|
||||||
|
|
||||||
public bool IsProgressShown => Status == UpdateStatus.Downloading;
|
|
||||||
|
|
||||||
public bool IsReleaseLinkShown => _release?.PageUrl is not null && Status is not UpdateStatus.Checking;
|
|
||||||
|
|
||||||
/// <summary>The release page: the release notes live there too.</summary>
|
|
||||||
public Uri? ReleaseUrl => _release?.PageUrl;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// What the section says. There is a line for every state, the one before the
|
|
||||||
/// first check included: the status is never an empty spot in the window.
|
|
||||||
/// </summary>
|
|
||||||
public string StatusText => Status switch
|
|
||||||
{
|
|
||||||
UpdateStatus.Checking => _localization["UpdateChecking"],
|
|
||||||
UpdateStatus.UpToDate => _localization["UpdateUpToDate"],
|
|
||||||
UpdateStatus.Available => Format("UpdateAvailable", _release?.Tag),
|
|
||||||
UpdateStatus.Downloading => _localization["UpdateDownloading"],
|
|
||||||
UpdateStatus.Ready => _localization["UpdateReady"],
|
|
||||||
UpdateStatus.Failed => _localization["UpdateFailed"],
|
|
||||||
_ => _localization["UpdateNotChecked"],
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Asks about new versions. Called once, when the window has just appeared.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// It used to be called when the application started, which back then meant when
|
|
||||||
/// the machine was switched on. The background half is a separate process now and
|
|
||||||
/// does not go to the network at all — nothing in it could show the answer — so the
|
|
||||||
/// question is asked when there is a window to answer into.
|
|
||||||
///
|
|
||||||
/// And it is asked every time that window appears: opening it is a deliberate act
|
|
||||||
/// of the user, rare enough that a request costs nothing, and the answer is what
|
|
||||||
/// the section exists for. A remembered answer from yesterday is worth less than
|
|
||||||
/// today's, so nothing is remembered.
|
|
||||||
/// </remarks>
|
|
||||||
public Task StartAsync() => CheckAsync();
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_localization.PropertyChanged -= OnLocalizationChanged;
|
|
||||||
|
|
||||||
_work?.Cancel();
|
|
||||||
_work?.Dispose();
|
|
||||||
_work = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task CheckAsync()
|
|
||||||
{
|
|
||||||
if (!IsSupported)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CancellationToken token = StartWork();
|
|
||||||
Status = UpdateStatus.Checking;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_release = await _updates.CheckAsync(token);
|
|
||||||
_packagePath = null;
|
|
||||||
|
|
||||||
Status = _release is null ? UpdateStatus.UpToDate : UpdateStatus.Available;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// The check was cancelled by the next piece of work: it has already set its own state
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (Exception e) when (IsExpected(e))
|
|
||||||
{
|
|
||||||
Status = UpdateStatus.Failed;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(ReleaseUrl));
|
|
||||||
OnPropertyChanged(nameof(IsReleaseLinkShown));
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task DownloadAsync()
|
|
||||||
{
|
|
||||||
if (_release is not { } release)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CancellationToken token = StartWork();
|
|
||||||
Progress = 0;
|
|
||||||
Status = UpdateStatus.Downloading;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var progress = new Progress<double>(value => Progress = value);
|
|
||||||
_packagePath = await _updates.DownloadAsync(release, progress, token);
|
|
||||||
Status = UpdateStatus.Ready;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// The download was interrupted by the next piece of work: it has already set its own state
|
|
||||||
}
|
|
||||||
catch (Exception e) when (IsExpected(e))
|
|
||||||
{
|
|
||||||
Status = UpdateStatus.Failed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void Install()
|
|
||||||
{
|
|
||||||
if (_packagePath is null || !File.Exists(_packagePath))
|
|
||||||
{
|
|
||||||
// The file was removed by the temp folder cleanup — downloading it again is what is left
|
|
||||||
Status = _release is null ? UpdateStatus.Idle : UpdateStatus.Available;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_updates.Install(_packagePath);
|
|
||||||
}
|
|
||||||
catch (Exception e) when (IsExpected(e))
|
|
||||||
{
|
|
||||||
Status = UpdateStatus.Failed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The errors that simply leave the update undone: an unreachable network, an
|
|
||||||
/// unexpected response, a file in use. Everything else is a reason to crash.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <c>OperationCanceledException</c> means an expired request deadline here:
|
|
||||||
/// cancellation by the application itself is caught by a separate handler above.
|
|
||||||
/// </remarks>
|
|
||||||
private static bool IsExpected(Exception e) =>
|
|
||||||
e is HttpRequestException or JsonException or IOException or UnauthorizedAccessException
|
|
||||||
or NotSupportedException or InvalidOperationException or Win32Exception
|
|
||||||
or OperationCanceledException;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts a new piece of work, cancelling the previous one: the user may have
|
|
||||||
/// pressed "Check" in the middle of a download.
|
|
||||||
/// </summary>
|
|
||||||
private CancellationToken StartWork()
|
|
||||||
{
|
|
||||||
_work?.Cancel();
|
|
||||||
_work?.Dispose();
|
|
||||||
_work = new CancellationTokenSource();
|
|
||||||
return _work.Token;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Format(string key, string? argument) =>
|
|
||||||
string.Format(CultureInfo.CurrentCulture, _localization[key], argument);
|
|
||||||
|
|
||||||
private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.PropertyName == Binding.IndexerName)
|
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(StatusText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
xmlns:vm="clr-namespace:CursorLang.Settings.ViewModels"
|
xmlns:vm="clr-namespace:CursorLang.Settings.ViewModels"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
|
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
|
||||||
Title="{Binding Localization[SettingsTitle]}"
|
Title="{Binding Title}"
|
||||||
Width="1000" SizeToContent="Height" MaxHeight="900"
|
Width="1000" SizeToContent="Height" MaxHeight="900"
|
||||||
ResizeMode="CanMinimize"
|
ResizeMode="CanMinimize"
|
||||||
Background="{DynamicResource Theme.WindowBackground}"
|
Background="{DynamicResource Theme.WindowBackground}"
|
||||||
@@ -196,72 +196,6 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
<!-- The section is absent for an app from the Store: the Store updates it itself -->
|
|
||||||
<GroupBox Header="{Binding Localization[SectionUpdates]}"
|
|
||||||
Visibility="{Binding Updates.IsSupported, Converter={StaticResource BooleanToVisibility}}">
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="164" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<TextBlock Style="{StaticResource FieldLabel}"
|
|
||||||
Text="{Binding Localization[CurrentVersionLabel]}" />
|
|
||||||
<TextBlock Grid.Column="1" VerticalAlignment="Center"
|
|
||||||
Text="{Binding Updates.CurrentVersion}" />
|
|
||||||
<Button Grid.Column="2" Padding="12,4"
|
|
||||||
Command="{Binding Updates.CheckCommand}"
|
|
||||||
IsEnabled="{Binding Updates.CanCheck}"
|
|
||||||
Content="{Binding Localization[CheckUpdatesButton]}" />
|
|
||||||
|
|
||||||
<!-- The status is always on show: the check flies off with the
|
|
||||||
window, and where things stand is the answer the user came for -->
|
|
||||||
<TextBlock Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2"
|
|
||||||
Margin="0,12,0,0" TextWrapping="Wrap"
|
|
||||||
Text="{Binding Updates.StatusText}" />
|
|
||||||
|
|
||||||
<!-- The downloaded fraction is not always known: not every hosting reports the file size -->
|
|
||||||
<ProgressBar Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2"
|
|
||||||
Margin="0,8,0,0" Height="4" Maximum="1"
|
|
||||||
Value="{Binding Updates.Progress, Mode=OneWay}"
|
|
||||||
IsIndeterminate="{Binding Updates.IsProgressUnknown}"
|
|
||||||
Visibility="{Binding Updates.IsProgressShown, Converter={StaticResource BooleanToVisibility}}" />
|
|
||||||
|
|
||||||
<StackPanel Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
|
|
||||||
Margin="0,12,0,0" Orientation="Horizontal">
|
|
||||||
<Button Padding="12,4"
|
|
||||||
Command="{Binding Updates.DownloadCommand}"
|
|
||||||
Visibility="{Binding Updates.IsDownloadOffered, Converter={StaticResource BooleanToVisibility}}"
|
|
||||||
Content="{Binding Localization[DownloadUpdateButton]}" />
|
|
||||||
<Button Padding="12,4"
|
|
||||||
Command="{Binding Updates.InstallCommand}"
|
|
||||||
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
|
|
||||||
Content="{Binding Localization[InstallUpdateButton]}" />
|
|
||||||
<TextBlock Margin="12,0,0,0" VerticalAlignment="Center"
|
|
||||||
Visibility="{Binding Updates.IsReleaseLinkShown, Converter={StaticResource BooleanToVisibility}}">
|
|
||||||
<Hyperlink NavigateUri="{Binding Updates.ReleaseUrl}"
|
|
||||||
RequestNavigate="OnReleaseLinkNavigate">
|
|
||||||
<Run Text="{Binding Localization[ReleasePageLink], Mode=OneWay}" />
|
|
||||||
</Hyperlink>
|
|
||||||
</TextBlock>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
|
|
||||||
Margin="0,6,0,0" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
|
||||||
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
|
|
||||||
Text="{Binding Localization[UpdateInstallHint]}" />
|
|
||||||
</Grid>
|
|
||||||
</GroupBox>
|
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Margin="16,0,0,0">
|
<StackPanel Grid.Column="1" Margin="16,0,0,0">
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Navigation;
|
|
||||||
using CursorLang.Settings.Services;
|
using CursorLang.Settings.Services;
|
||||||
using CursorLang.Settings.ViewModels;
|
using CursorLang.Settings.ViewModels;
|
||||||
|
|
||||||
@@ -28,23 +25,4 @@ public partial class MainWindow : Window
|
|||||||
theme.Register(this);
|
theme.Register(this);
|
||||||
placement.Attach(this);
|
placement.Attach(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the release page in a browser. A link in WPF leads nowhere on its own:
|
|
||||||
/// where to hand it over is up to the application.
|
|
||||||
/// </summary>
|
|
||||||
private void OnReleaseLinkNavigate(object sender, RequestNavigateEventArgs e)
|
|
||||||
{
|
|
||||||
e.Handled = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true })?.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is Win32Exception or InvalidOperationException)
|
|
||||||
{
|
|
||||||
// There is no browser in the system — that does not get in the way of the
|
|
||||||
// update, which is downloaded by the button next to it anyway
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace CursorLang.Tests.Shared;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The network as the test writes it: the answer is decided here rather than
|
|
||||||
/// by a repository somewhere.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class FakeHttpHandler : HttpMessageHandler
|
|
||||||
{
|
|
||||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _reply;
|
|
||||||
|
|
||||||
public FakeHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> reply) => _reply = reply;
|
|
||||||
|
|
||||||
public List<HttpRequestMessage> Requests { get; } = [];
|
|
||||||
|
|
||||||
public static FakeHttpHandler Json(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
|
||||||
});
|
|
||||||
|
|
||||||
public static FakeHttpHandler Status(HttpStatusCode status) =>
|
|
||||||
new(_ => new HttpResponseMessage(status));
|
|
||||||
|
|
||||||
public static FakeHttpHandler Bytes(byte[] content) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
Content = new ByteArrayContent(content),
|
|
||||||
});
|
|
||||||
|
|
||||||
public HttpClient CreateClient() => new(this);
|
|
||||||
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
|
||||||
HttpRequestMessage request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
Requests.Add(request);
|
|
||||||
return Task.FromResult(_reply(request));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -120,75 +120,6 @@ public sealed class FakeStartupService : IStartupService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Releases the test writes itself, with no repository behind them.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class FakeUpdateService : IUpdateService
|
|
||||||
{
|
|
||||||
public ReleaseInfo? Release { get; set; }
|
|
||||||
|
|
||||||
public Exception? Failure { get; set; }
|
|
||||||
|
|
||||||
public TaskCompletionSource? DownloadGate { get; set; }
|
|
||||||
|
|
||||||
public string PackagePath { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public int CheckCalls { get; private set; }
|
|
||||||
|
|
||||||
public List<string> Installed { get; } = [];
|
|
||||||
|
|
||||||
public bool IsSupported { get; set; } = true;
|
|
||||||
|
|
||||||
public Version CurrentVersion { get; set; } = new(1, 0, 0, 0);
|
|
||||||
|
|
||||||
public Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
CheckCalls++;
|
|
||||||
|
|
||||||
return Failure is null
|
|
||||||
? Task.FromResult(Release)
|
|
||||||
: Task.FromException<ReleaseInfo?>(Failure);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> DownloadAsync(
|
|
||||||
ReleaseInfo release,
|
|
||||||
IProgress<double>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (DownloadGate is not null)
|
|
||||||
{
|
|
||||||
await DownloadGate.Task.WaitAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Failure is not null)
|
|
||||||
{
|
|
||||||
throw Failure;
|
|
||||||
}
|
|
||||||
|
|
||||||
progress?.Report(0.5);
|
|
||||||
return PackagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Install(string packagePath) => Installed.Add(packagePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A release list the test fills in, with no repository behind it.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class FakeReleaseFeed : IReleaseFeed
|
|
||||||
{
|
|
||||||
public ReleaseInfo? Release { get; set; }
|
|
||||||
|
|
||||||
public Exception? Failure { get; set; }
|
|
||||||
|
|
||||||
public List<HttpRequestMessage> Authorized { get; } = [];
|
|
||||||
|
|
||||||
public Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken) =>
|
|
||||||
Failure is null ? Task.FromResult(Release) : Task.FromException<ReleaseInfo?>(Failure);
|
|
||||||
|
|
||||||
public void Authorize(HttpRequestMessage request) => Authorized.Add(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interface strings without resources: the key comes back as is, tagged with the language.
|
/// Interface strings without resources: the key comes back as is, tagged with the language.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -200,12 +131,20 @@ public sealed class FakeLocalizationService : ILocalizationService
|
|||||||
|
|
||||||
public List<string> RequestedKeys { get; } = [];
|
public List<string> RequestedKeys { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strings the test writes out in full — for the ones it puts values into.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> Strings { get; } = [];
|
||||||
|
|
||||||
public string this[string key]
|
public string this[string key]
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
RequestedKeys.Add(key);
|
RequestedKeys.Add(key);
|
||||||
return $"{_currentLanguage}:{key}";
|
|
||||||
|
return Strings.TryGetValue(key, out string? value)
|
||||||
|
? value
|
||||||
|
: $"{_currentLanguage}:{key}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The installer for handing the application round outside the Store.
|
||||||
|
|
||||||
|
The Store gets an MSIX and signs it itself; this one nobody signs, so Windows
|
||||||
|
warns about an unknown publisher and the user has to insist. That is the price
|
||||||
|
of an unsigned build, and short of a certificate from a trusted authority there
|
||||||
|
is no way round it.
|
||||||
|
|
||||||
|
Built by build-installer.ps1 — it publishes both halves of the application and
|
||||||
|
passes the version, the architecture and the folders in as preprocessor
|
||||||
|
variables.
|
||||||
|
-->
|
||||||
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||||
|
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"
|
||||||
|
xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
|
||||||
|
|
||||||
|
<Package
|
||||||
|
Name="CursorLang"
|
||||||
|
Manufacturer="Aleksandr Neichev"
|
||||||
|
Version="$(var.Version)"
|
||||||
|
UpgradeCode="B7E4C9A2-5D31-4F86-9E27-3A1C8B0D6F45"
|
||||||
|
Compressed="yes"
|
||||||
|
Scope="perUser">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Installing for the current user alone asks for no administrator rights,
|
||||||
|
and so for no consent dialog either. The application needs nothing
|
||||||
|
beyond the user's own account, and one awkward question the less is
|
||||||
|
worth more here than a copy shared by everyone who signs in.
|
||||||
|
|
||||||
|
MSI was designed around installing for the whole machine, and its
|
||||||
|
validation rules still say so: the ICE checks this trips over are
|
||||||
|
turned off in the project file, not answered.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Everything goes inside the .msi. Left to itself WiX puts the cabinet
|
||||||
|
beside the file, and an installer that has to travel with a second
|
||||||
|
file next to it is no use to anyone it is sent to
|
||||||
|
-->
|
||||||
|
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||||
|
|
||||||
|
<MajorUpgrade DowngradeErrorMessage="A newer version of CursorLang is already installed." />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Both halves may be running, and both hold on to files in the install
|
||||||
|
folder. Named here, they are asked to close instead of the install
|
||||||
|
failing halfway through on a file it cannot replace
|
||||||
|
-->
|
||||||
|
<util:CloseApplication Id="CloseAgent" Target="CursorLang.exe"
|
||||||
|
CloseMessage="yes" RebootPrompt="no" />
|
||||||
|
<util:CloseApplication Id="CloseSettings" Target="CursorLang.Settings.exe"
|
||||||
|
CloseMessage="yes" RebootPrompt="no" />
|
||||||
|
|
||||||
|
<Icon Id="CursorLangIcon" SourceFile="$(var.IconFile)" />
|
||||||
|
<Property Id="ARPPRODUCTICON" Value="CursorLangIcon" />
|
||||||
|
<Property Id="ARPNOREPAIR" Value="1" />
|
||||||
|
|
||||||
|
<!-- Only the folder is asked about: everything here is installed, always -->
|
||||||
|
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
|
||||||
|
<WixVariable Id="WixUILicenseRtf" Value="$(var.LicenseFile)" />
|
||||||
|
|
||||||
|
<StandardDirectory Id="LocalAppDataFolder">
|
||||||
|
<Directory Id="ProgramsFolder" Name="Programs">
|
||||||
|
<Directory Id="INSTALLFOLDER" Name="CursorLang" />
|
||||||
|
</Directory>
|
||||||
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<StandardDirectory Id="ProgramMenuFolder">
|
||||||
|
<Directory Id="ShortcutFolder" Name="CursorLang" />
|
||||||
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<Feature Id="Main" Title="CursorLang" Level="1">
|
||||||
|
<ComponentGroupRef Id="Payload" />
|
||||||
|
<ComponentRef Id="StartMenuShortcut" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The whole published folder: two executables, the libraries and the
|
||||||
|
runtime. Listing them one by one would mean rewriting this file every
|
||||||
|
time the runtime changes shape
|
||||||
|
-->
|
||||||
|
<ComponentGroup Id="Payload" Directory="INSTALLFOLDER">
|
||||||
|
<Files Include="$(var.PayloadDir)\**" />
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The shortcut starts the agent — the half that lives in the tray and
|
||||||
|
shows the layout. The settings window opens from its menu, so it needs
|
||||||
|
no shortcut of its own.
|
||||||
|
|
||||||
|
The key path is a registry value rather than the shortcut file: for
|
||||||
|
anything installed into the user's profile Windows Installer wants it
|
||||||
|
that way, and a shortcut cannot serve as one
|
||||||
|
-->
|
||||||
|
<Component Id="StartMenuShortcut" Directory="ShortcutFolder" Guid="D4A81C36-7E52-4B19-A0F3-6C2E9D5B8471">
|
||||||
|
<Shortcut Id="CursorLangShortcut"
|
||||||
|
Name="CursorLang"
|
||||||
|
Description="Shows the keyboard layout at the cursor"
|
||||||
|
Target="[INSTALLFOLDER]CursorLang.exe"
|
||||||
|
WorkingDirectory="INSTALLFOLDER" />
|
||||||
|
<RemoveFolder Id="RemoveShortcutFolder" Directory="ShortcutFolder" On="uninstall" />
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\CursorLang" Name="Shortcut"
|
||||||
|
Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The startup entry is written by the application itself, when startup is
|
||||||
|
switched on in its settings, and has to go when the application does:
|
||||||
|
left behind, it would point at an executable that is gone and Windows
|
||||||
|
would go on listing it in the startup list for good.
|
||||||
|
|
||||||
|
RemoveRegistryValue would have been the obvious way to say that, but it
|
||||||
|
means the opposite of what it sounds like — it wipes the value as the
|
||||||
|
product is installed, which would quietly switch startup off for anyone
|
||||||
|
who had turned it on and then upgraded. Hence reg.exe on the way out.
|
||||||
|
|
||||||
|
UPGRADINGPRODUCTCODE tells an uninstall apart from the removal of the
|
||||||
|
old version during an upgrade: the second one has to leave the entry
|
||||||
|
alone, or upgrading would cost the user their startup setting anyway.
|
||||||
|
-->
|
||||||
|
<CustomAction Id="ForgetStartup" Directory="INSTALLFOLDER" Execute="deferred"
|
||||||
|
Impersonate="yes" Return="ignore"
|
||||||
|
ExeCommand=""[SystemFolder]reg.exe" delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v CursorLang /f" />
|
||||||
|
|
||||||
|
<CustomAction Id="ForgetStartupApproval" Directory="INSTALLFOLDER" Execute="deferred"
|
||||||
|
Impersonate="yes" Return="ignore"
|
||||||
|
ExeCommand=""[SystemFolder]reg.exe" delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v CursorLang /f" />
|
||||||
|
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action="ForgetStartup" Before="RemoveFiles"
|
||||||
|
Condition="REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE" />
|
||||||
|
<Custom Action="ForgetStartupApproval" After="ForgetStartup"
|
||||||
|
Condition="REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE" />
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
|
<!-- Offered at the end of the wizard, the way an installer usually does -->
|
||||||
|
<Property Id="WixShellExecTarget" Value="[#CursorLang.exe]" />
|
||||||
|
<CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
|
||||||
|
DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
|
||||||
|
|
||||||
|
<UI>
|
||||||
|
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication"
|
||||||
|
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed" />
|
||||||
|
</UI>
|
||||||
|
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Start CursorLang" />
|
||||||
|
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
|
||||||
|
|
||||||
|
</Package>
|
||||||
|
</Wix>
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
meant to be built — build-msix.ps1 restores it and takes the program from the
|
meant to be built — build-msix.ps1 restores it and takes the program from the
|
||||||
packages folder. It is kept out of the build in CursorLang.sln for the same
|
packages folder. It is kept out of the build in CursorLang.sln for the same
|
||||||
reason.
|
reason.
|
||||||
|
|
||||||
|
The installer needs nothing from here: WiX arrives with its own project, at
|
||||||
|
Installer\CursorLang.wixproj.
|
||||||
-->
|
-->
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
|||||||
+45
-75
@@ -1,6 +1,6 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Builds the CursorLang MSIX package for the Microsoft Store.
|
Builds the CursorLang MSIX package.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a
|
Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
plain .NET SDK.
|
plain .NET SDK.
|
||||||
|
|
||||||
The package is handed to Partner Center as it comes out of here — the Store
|
The package is handed to Partner Center as it comes out of here — the Store
|
||||||
is where it gets everything else done to it.
|
puts its own signature on it and does the rest. Nothing is signed here: a
|
||||||
|
publicly trusted code signing certificate is not to be had, and the Store
|
||||||
|
asks for none.
|
||||||
|
|
||||||
The application is published with its own copy of .NET: Windows does not
|
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.
|
carry one, and MSIX cannot install the runtime as a package dependency.
|
||||||
@@ -23,18 +25,14 @@
|
|||||||
be on; a signature is not needed, because what gets registered is the layout
|
be on; a signature is not needed, because what gets registered is the layout
|
||||||
the package is made of rather than the package file.
|
the package is made of rather than the package file.
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
# A check on your own machine: your architecture alone
|
|
||||||
pwsh -File Packaging\build-msix.ps1 -Architectures x64
|
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
# Build and install in one go, to click through the application
|
# Build and install in one go, to click through the application
|
||||||
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
|
pwsh -File Packaging\build-msix.ps1 -Install
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
# A build for the Store — the identity comes from Partner Center
|
# A build for the Store — the identity comes from Partner Center
|
||||||
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
||||||
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
|
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||||
#>
|
#>
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
@@ -42,11 +40,8 @@ param(
|
|||||||
[string] $Version = '1.0.0.0',
|
[string] $Version = '1.0.0.0',
|
||||||
|
|
||||||
[string] $IdentityName = 'CursorLang',
|
[string] $IdentityName = 'CursorLang',
|
||||||
[string] $Publisher = 'CN=Aleksandr Neychev',
|
[string] $Publisher = 'CN=Aleksandr Neichev',
|
||||||
[string] $PublisherDisplayName = 'Aleksandr Neychev',
|
[string] $PublisherDisplayName = 'Aleksandr Neichev',
|
||||||
|
|
||||||
[ValidateSet('x64', 'arm64')]
|
|
||||||
[string[]] $Architectures = @('x64', 'arm64'),
|
|
||||||
|
|
||||||
[switch] $Install,
|
[switch] $Install,
|
||||||
|
|
||||||
@@ -64,7 +59,14 @@ $assets = Join-Path $root 'Assets'
|
|||||||
$manifestTemplate = Join-Path $root 'AppxManifest.xml'
|
$manifestTemplate = Join-Path $root 'AppxManifest.xml'
|
||||||
$toolsProject = Join-Path $root 'Tools\SdkTools.csproj'
|
$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'
|
$layoutRoot = Join-Path $OutputPath 'layout'
|
||||||
$packagesPath = Join-Path $OutputPath 'packages'
|
$packagesPath = Join-Path $OutputPath 'packages'
|
||||||
|
|
||||||
@@ -77,9 +79,8 @@ if (-not (Test-Path $assets)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($Install) {
|
if ($Install) {
|
||||||
# Both things below are checked before the build rather than after it: the
|
# Checked before the build rather than after it: the build takes minutes, and
|
||||||
# build takes minutes, and neither of them gets any truer while it runs
|
# this does not get any truer while it runs
|
||||||
|
|
||||||
$developerMode = Get-ItemPropertyValue `
|
$developerMode = Get-ItemPropertyValue `
|
||||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' `
|
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' `
|
||||||
-Name 'AllowDevelopmentWithoutDevLicense' -ErrorAction SilentlyContinue
|
-Name 'AllowDevelopmentWithoutDevLicense' -ErrorAction SilentlyContinue
|
||||||
@@ -87,12 +88,6 @@ if ($Install) {
|
|||||||
if ($developerMode -ne 1) {
|
if ($developerMode -ne 1) {
|
||||||
throw 'Installing needs developer mode: Settings - System - For developers - Developer mode. Without a signature Windows registers a package no other way.'
|
throw 'Installing needs developer mode: Settings - System - For developers - Developer mode. Without a signature Windows registers a package no other way.'
|
||||||
}
|
}
|
||||||
|
|
||||||
$machineArchitecture = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
|
|
||||||
|
|
||||||
if ($Architectures -notcontains $machineArchitecture) {
|
|
||||||
throw "This machine is $machineArchitecture, and that architecture is not being built. Add it to -Architectures, or drop -Install."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-Tool {
|
function Invoke-Tool {
|
||||||
@@ -189,71 +184,46 @@ foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) {
|
|||||||
Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null
|
New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null
|
||||||
|
|
||||||
$built = @()
|
Write-Host 'Building...' -ForegroundColor Cyan
|
||||||
|
|
||||||
foreach ($architecture in $Architectures) {
|
$layout = Join-Path $layoutRoot 'x64'
|
||||||
Write-Host "Building $architecture..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
$layout = Join-Path $layoutRoot $architecture
|
foreach ($half in @($agentProject, $settingsProject)) {
|
||||||
|
Invoke-Tool -Path 'dotnet' -Arguments @(
|
||||||
foreach ($half in @($agentProject, $settingsProject)) {
|
'publish', $half,
|
||||||
Invoke-Tool -Path 'dotnet' -Arguments @(
|
'--configuration', 'Release',
|
||||||
'publish', $half,
|
'--runtime', 'win-x64',
|
||||||
'--configuration', 'Release',
|
'--self-contained', 'true',
|
||||||
'--runtime', "win-$architecture",
|
"-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))",
|
||||||
'--self-contained', 'true',
|
'--output', $layout,
|
||||||
"-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))",
|
'--nologo'
|
||||||
'--output', $layout,
|
)
|
||||||
'--nologo'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Debug symbols have no place in the package: they take up room, the user
|
|
||||||
# has no use for them, and for crash reports the Store takes them separately
|
|
||||||
Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force
|
|
||||||
|
|
||||||
Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
|
|
||||||
|
|
||||||
$manifest = (Get-Content $manifestTemplate -Raw).
|
|
||||||
Replace('{IdentityName}', $IdentityName).
|
|
||||||
Replace('{Publisher}', $Publisher).
|
|
||||||
Replace('{PublisherDisplayName}', $PublisherDisplayName).
|
|
||||||
Replace('{Version}', $Version).
|
|
||||||
Replace('{Architecture}', $architecture)
|
|
||||||
|
|
||||||
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
|
|
||||||
|
|
||||||
$package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix"
|
|
||||||
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package)
|
|
||||||
|
|
||||||
$built += $package
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $built[0]
|
# Debug symbols have no place in the package: they take up room, the user
|
||||||
|
# has no use for them, and for crash reports the Store takes them separately
|
||||||
|
Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force
|
||||||
|
|
||||||
if ($built.Count -gt 1) {
|
Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
|
||||||
Write-Host 'Bringing the architectures into one package...' -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# makeappx bundle takes everything from a folder, so the separate packages
|
$manifest = (Get-Content $manifestTemplate -Raw).
|
||||||
# are gathered into one of their own first — otherwise the results of
|
Replace('{IdentityName}', $IdentityName).
|
||||||
# earlier builds would end up in the bundle
|
Replace('{Publisher}', $Publisher).
|
||||||
$bundleInput = Join-Path $OutputPath 'bundle'
|
Replace('{PublisherDisplayName}', $PublisherDisplayName).
|
||||||
Remove-Item $bundleInput -Recurse -Force -ErrorAction SilentlyContinue
|
Replace('{Version}', $Version).
|
||||||
New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null
|
Replace('{Architecture}', 'x64')
|
||||||
$built | ForEach-Object { Copy-Item $_ -Destination $bundleInput }
|
|
||||||
|
|
||||||
$result = Join-Path $packagesPath "CursorLang-$Version.msixbundle"
|
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
|
||||||
Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version)
|
|
||||||
|
|
||||||
Remove-Item $bundleInput -Recurse -Force
|
$result = Join-Path $packagesPath "CursorLang-$Version-x64.msix"
|
||||||
}
|
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $result)
|
||||||
|
|
||||||
if ($Install) {
|
if ($Install) {
|
||||||
Write-Host "Installing the $machineArchitecture build..." -ForegroundColor Cyan
|
Write-Host 'Installing...' -ForegroundColor Cyan
|
||||||
|
|
||||||
# The layout is registered rather than the package file: the two hold the
|
# The layout is registered rather than the package file: the two hold the
|
||||||
# same thing, but a package file Windows only installs when it is signed
|
# same thing, but a package file Windows only installs when it is signed
|
||||||
Add-AppxPackage -Register (Join-Path $layoutRoot "$machineArchitecture\AppxManifest.xml")
|
Add-AppxPackage -Register (Join-Path $layout 'AppxManifest.xml')
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
|
|||||||
+80
-66
@@ -81,8 +81,8 @@ MSIX всегда выполняются в контексте вошедшег
|
|||||||
открытии окна.
|
открытии окна.
|
||||||
|
|
||||||
`CursorLang.Core.dll` — общее для обоих: модели, файл настроек, слежение за
|
`CursorLang.Core.dll` — общее для обоих: модели, файл настроек, слежение за
|
||||||
раскладкой, обновления. Без UI, и так должно остаться: всё, что попадёт туда,
|
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
|
||||||
попадёт и в фоновый процесс.
|
в фоновый процесс.
|
||||||
|
|
||||||
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный
|
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный
|
||||||
файл, который одним движением встаёт на место, и посылает агенту
|
файл, который одним движением встаёт на место, и посылает агенту
|
||||||
@@ -167,56 +167,21 @@ MSIX всегда выполняются в контексте вошедшег
|
|||||||
|
|
||||||
## Обновления
|
## Обновления
|
||||||
|
|
||||||
Проверка выполняется при открытии окна настроек, а не при включении машины:
|
Приложение обновляет Store, а само приложение об этом не заботится: раздела
|
||||||
фоновая половина в сеть больше не ходит вовсе, да и показать ответ ей нечем.
|
обновлений в окне нет, запросов в сеть нет и кода для них тоже нет.
|
||||||
Настройка в окне так и написана.
|
|
||||||
|
|
||||||
Приложение ищет новые версии среди выпусков собственного репозитория. Выпуск
|
Дело не во вкусе, а в цене подписи. MSIX Windows установит только тогда, когда
|
||||||
годится, если его тег — это просто версия (`v1.2.3` или `1.2.3`) и к нему
|
доверяет подписи на нём, а публично доверенный сертификат для подписи кода
|
||||||
приложен пакет MSIX. Тег, в котором есть что-то ещё, — в том числе `v1.2.3-beta`
|
оказался недосягаем: удостоверяющие центры, которые их продают, здесь его не
|
||||||
— пропускается: предварительную версию берут намеренно, приложение её не
|
выдают, а те, что держали бы ключ в облачном HSM, — тем более, а сертификат на
|
||||||
предлагает.
|
USB-токене сюда не привезти. Без подписи пакет установится только на машине в
|
||||||
|
режиме разработчика, значит выкладывать в выпуск нечего и искать обновления
|
||||||
|
негде. Store подписывает пакет своим сертификатом и обновляет приложение сам —
|
||||||
|
на этом вопрос и закрыт.
|
||||||
|
|
||||||
Из приложенных файлов предпочитается `.msixbundle` — он несёт обе архитектуры.
|
Версия работающего приложения стоит в заголовке окна настроек:
|
||||||
Если его нет, берётся пакет, в имени которого стоит архитектура этой машины:
|
`CursorLang 1.2.3 — Настройки`. Больше её в интерфейсе нигде нет, и стоит она
|
||||||
`CursorLang-1.2.3.0-x64.msix`. Такие имена даёт `build-msix.ps1`, так что выпуск
|
там для того, чтобы её можно было назвать в сообщении об ошибке.
|
||||||
делается прикладыванием того, что он собрал.
|
|
||||||
|
|
||||||
Пакет скачивается во временную папку и передаётся установщику приложений
|
|
||||||
Windows: тот показывает издателя, спрашивает подтверждение и заменяет
|
|
||||||
установленную версию. Подпись проверяет Windows, поэтому приложенный к выпуску
|
|
||||||
пакет должен быть подписан — неподписанный установится только на машине в режиме
|
|
||||||
разработчика. Работающее приложение до перезапуска продолжает жить на старых
|
|
||||||
файлах.
|
|
||||||
|
|
||||||
У приложения, установленного из Store, раздела обновлений нет вовсе: его
|
|
||||||
обновляет Store, а пакет со стороны Windows поверх него всё равно не примет.
|
|
||||||
|
|
||||||
Приложение спрашивает о выпусках при каждом появлении окна настроек, и ответ
|
|
||||||
остаётся в разделе как есть — включая недоступную сеть. Открытие этого окна —
|
|
||||||
намеренное действие, достаточно редкое, чтобы запрос ничего не стоил, поэтому
|
|
||||||
между открытиями ничего не запоминается и выключать нечего. Кнопка рядом с
|
|
||||||
версией задаёт тот же вопрос заново по требованию.
|
|
||||||
|
|
||||||
Где искать выпуски, задаётся в `UpdateOptions`: репозиторий принадлежит тому, кто
|
|
||||||
выпускает приложение, а не пользователю, поэтому значения живут в сборке, а не в
|
|
||||||
`settings.json`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
services.AddSingleton(new UpdateOptions
|
|
||||||
{
|
|
||||||
ServiceUri = new Uri("https://git.alrakis.kz/"), // сам сервер Gitea
|
|
||||||
Project = "alrakis/cursor-lang",
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Выпуски берутся из Gitea, а её API живёт на самом сервере: адрес — тот же, по
|
|
||||||
которому репозиторий открывают в браузере, и под ним приложение спрашивает
|
|
||||||
`/api/v1/repos/{владелец}/{репозиторий}/releases`.
|
|
||||||
|
|
||||||
Закрытому репозиторию нужен токен. Он читается из переменной окружения
|
|
||||||
`CURSORLANG_UPDATE_TOKEN`, а не хранится в исходниках: секрет, встроенный в
|
|
||||||
сборку, — это секрет, отданный всем, кто эту сборку получил.
|
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
@@ -262,11 +227,11 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
|
|||||||
|
|
||||||
Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос
|
Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос
|
||||||
на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3`
|
на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3`
|
||||||
проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом
|
проект собирается, проходит тесты и пакуется в MSIX, который остаётся
|
||||||
с приложенными пакетами. Номер версии берётся только из тега — тег любого
|
в артефактах прогона. Номер версии берётся только из тега — тег любого другого
|
||||||
другого вида останавливает прогон в самом начале. Версия пакета получается
|
вида останавливает прогон в самом начале. Версия пакета получается `1.2.3.0`:
|
||||||
`1.2.3.0`: Store принимает четыре числа и последнее оставляет себе, так что тег
|
Store принимает четыре числа и последнее оставляет себе, так что тег на него не
|
||||||
на него не влияет.
|
влияет.
|
||||||
|
|
||||||
Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём —
|
Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём —
|
||||||
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
|
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
|
||||||
@@ -276,10 +241,18 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
|
|||||||
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
|
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
|
||||||
сеансе, пропускают себя: показать окно там негде.
|
сеансе, пропускают себя: показать окно там негде.
|
||||||
|
|
||||||
Пакет, который несёт релиз, загружается в Partner Center как есть. Identity
|
Пакет идёт в Store и больше никуда: он не подписан, подпись на него ставит сам
|
||||||
берётся из переменных репозитория, а если те не заданы — из значений по
|
Partner Center. Поэтому прогон оставляет его в артефактах под именем
|
||||||
|
`msix-1.2.3.0`, откуда его забирают и загружают руками; к релизу не прикладывается
|
||||||
|
ничего — релиз по тегу Gitea заводит сама, и в нём один только тег. Неподписанный
|
||||||
|
пакет, висящий в релизе, выглядел бы как то, что можно установить, и не
|
||||||
|
устанавливался бы нигде — см. раздел об обновлениях.
|
||||||
|
|
||||||
|
Identity берётся из переменных репозитория, а если те не заданы — из значений по
|
||||||
умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
|
умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
|
||||||
и `MSIX_PUBLISHER_DISPLAY_NAME`.
|
и `MSIX_PUBLISHER_DISPLAY_NAME`. Вместе identity и publisher задают family name
|
||||||
|
пакета, поэтому от версии к версии оба должны оставаться прежними — иначе Store
|
||||||
|
примет следующую за другое приложение.
|
||||||
|
|
||||||
## Сборка пакета MSIX
|
## Сборка пакета MSIX
|
||||||
|
|
||||||
@@ -292,17 +265,22 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
|
|||||||
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
|
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
|
||||||
powershell -File Packaging\New-Assets.ps1
|
powershell -File Packaging\New-Assets.ps1
|
||||||
|
|
||||||
# Проверка на своей машине: только своя архитектура
|
# Проверка на своей машине
|
||||||
powershell -File Packaging\build-msix.ps1 -Architectures x64
|
powershell -File Packaging\build-msix.ps1
|
||||||
|
|
||||||
# Для Partner Center — identity та, что зарезервирована там
|
# Для Partner Center — identity та, что зарезервирована там
|
||||||
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
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
|
Результат — `artifacts\packages\CursorLang-<версия>-x64.msix`. Он загружается в
|
||||||
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
|
Partner Center как есть.
|
||||||
Center как есть.
|
|
||||||
|
Собирается только x64. Сборка под arm64 удвоила бы вес каждого релиза ради
|
||||||
|
машин, которые и так выполняют x64 через эмуляцию.
|
||||||
|
|
||||||
|
Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки
|
||||||
|
на эту машину вместо пакета регистрируется layout — см. `-Install` ниже.
|
||||||
|
|
||||||
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
|
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
|
||||||
MSIX не может установить среду выполнения как зависимость пакета.
|
MSIX не может установить среду выполнения как зависимость пакета.
|
||||||
@@ -310,7 +288,7 @@ MSIX не может установить среду выполнения как
|
|||||||
Чтобы посмотреть, как пакет работает на этой машине, соберите его с `-Install`:
|
Чтобы посмотреть, как пакет работает на этой машине, соберите его с `-Install`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
|
pwsh -File Packaging\build-msix.ps1 -Install
|
||||||
```
|
```
|
||||||
|
|
||||||
Приложение появится в меню «Пуск» как любое установленное. Регистрируется не сам
|
Приложение появится в меню «Пуск» как любое установленное. Регистрируется не сам
|
||||||
@@ -324,5 +302,41 @@ pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
|
|||||||
Удалить вручную:
|
Удалить вручную:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName
|
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Сборка установщика
|
||||||
|
|
||||||
|
То же приложение собирается и обычным MSI — чтобы раздавать помимо Store: пока
|
||||||
|
Store не вынес решение или если так его и не вынесет. Ставить ничего, кроме .NET
|
||||||
|
SDK, не нужно: WiX приезжает пакетом NuGet, как и `makeappx`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Собрать и запустить — посмотреть глазами пользователя
|
||||||
|
pwsh -File Packaging\build-installer.ps1 -Install
|
||||||
|
|
||||||
|
# Всё, что нужно релизу
|
||||||
|
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат — `artifacts\installers\CursorLang-<версия>-x64.msi`. Всё лежит внутри
|
||||||
|
`.msi` — отдельного архива рядом с ним нет.
|
||||||
|
|
||||||
|
Устанавливается только для текущего пользователя, в
|
||||||
|
`%LOCALAPPDATA%\Programs\CursorLang`, поэтому не просит ни прав администратора,
|
||||||
|
ни подтверждения. Удаление идёт через «Параметры» — «Приложения», как у любой
|
||||||
|
программы, и уносит с собой запись автозапуска: иначе Windows продолжала бы
|
||||||
|
показывать в автозагрузке приложение, которого уже нет.
|
||||||
|
|
||||||
|
Установщик никто не подписывает, поэтому Windows предупреждает о неизвестном
|
||||||
|
издателе и пользователю приходится настоять. Покупка сертификата это сразу не
|
||||||
|
снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока
|
||||||
|
приложение не наберёт установок.
|
||||||
|
|
||||||
|
Про проект WiX стоит знать две вещи, прежде чем его править. Он закреплён на WiX
|
||||||
|
5, а не на нынешней 7: начиная с шестой версии инструмент требует принимать
|
||||||
|
лицензию Open Source Maintenance Fee — бесплатную при доходе меньше $10 000 в
|
||||||
|
год, но принимать её должен человек, а не сборочный скрипт. И он отключает три
|
||||||
|
проверки ICE: MSI по-прежнему исходит из установки на всю машину, а установка в
|
||||||
|
профиль пользователя нарушает правила, которые описывают ровно то, что здесь и
|
||||||
|
задумано.
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ WPF took goes back to the system. The price is a cold start of half a second or
|
|||||||
the next time the window is asked for.
|
the next time the window is asked for.
|
||||||
|
|
||||||
`CursorLang.Core.dll` is what both hold in common: the models, the settings file,
|
`CursorLang.Core.dll` is what both hold in common: the models, the settings file,
|
||||||
the layout tracking, the updates. No UI, and it must stay that way — whatever lands
|
the layout tracking. No UI, and it must stay that way — whatever lands there lands
|
||||||
there lands in the background process.
|
in the background process.
|
||||||
|
|
||||||
The connection between the two is `settings.json` and nothing else. The window
|
The connection between the two is `settings.json` and nothing else. The window
|
||||||
writes it — whole, into a temporary file moved into place in one step — and then
|
writes it — whole, into a temporary file moved into place in one step — and then
|
||||||
@@ -162,57 +162,22 @@ after a switch of the mode, and that is a question asked once.
|
|||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
The check runs when the settings window is opened, not when the machine is
|
The Store updates the app, and the app itself does nothing about it: there is no
|
||||||
switched on: the background half no longer goes to the network at all, and there
|
updates section in the window, no request to the network and no code for either.
|
||||||
would be nothing in it to show the answer. The setting in the window says as much.
|
|
||||||
|
|
||||||
The app looks for new versions among the releases of its own repository. A
|
That is not a matter of taste but of what a signature costs. Windows installs an
|
||||||
release counts when its tag is a plain version — `v1.2.3` or `1.2.3` — and an
|
MSIX only when it trusts the signature on it, and a publicly trusted code signing
|
||||||
MSIX package is attached to it. A tag with anything else in it, `v1.2.3-beta`
|
certificate has turned out to be beyond reach — the certificate authorities that
|
||||||
among them, is passed over: a pre-release version is asked for on purpose, not
|
sell them will not issue one here, and those that would keep the key in a cloud
|
||||||
offered by the app.
|
HSM will not either, while a certificate on a USB token cannot be shipped in.
|
||||||
|
Without a signature a package installs nowhere but a machine in developer mode,
|
||||||
|
so there is nothing to hand out from a release and nothing for an updater to
|
||||||
|
find. The Store signs the package with its own certificate and updates the app
|
||||||
|
by itself, which leaves the whole question to it.
|
||||||
|
|
||||||
Out of the attached files the `.msixbundle` is preferred — it carries both
|
The version of the running app is in the title of the settings window:
|
||||||
architectures. Failing that, the package whose name holds the architecture of
|
`CursorLang 1.2.3 — Settings`. It is nowhere else in the interface, and it is
|
||||||
this machine is taken: `CursorLang-1.2.3.0-x64.msix`. Those are the names
|
there so that a bug report can name it.
|
||||||
`build-msix.ps1` produces, so a release is made by attaching what it built.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
over it anyway.
|
|
||||||
|
|
||||||
The app asks about releases every time the settings window appears, and the
|
|
||||||
answer stays in the section as it is — an unreachable network included. Opening
|
|
||||||
that window is a deliberate act, rare enough for a request to cost nothing, so
|
|
||||||
nothing is remembered between openings and there is nothing to turn off. The
|
|
||||||
button next to the version asks the same question again on demand.
|
|
||||||
|
|
||||||
Where the releases are looked for is set in `UpdateOptions` — the repository
|
|
||||||
belongs to whoever publishes the app, not to the user, so the values live in the
|
|
||||||
build rather than in `settings.json`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
services.AddSingleton(new UpdateOptions
|
|
||||||
{
|
|
||||||
ServiceUri = new Uri("https://git.alrakis.kz/"), // the Gitea server itself
|
|
||||||
Project = "alrakis/cursor-lang",
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
The releases come from Gitea, and its API sits on the server itself: the address
|
|
||||||
is the one the repository is opened at in a browser, and the app asks
|
|
||||||
`/api/v1/repos/{owner}/{repo}/releases` under it.
|
|
||||||
|
|
||||||
A closed repository needs a token. It is read from the `CURSORLANG_UPDATE_TOKEN`
|
|
||||||
environment variable rather than kept in the source: a secret built into the app
|
|
||||||
is a secret handed to everyone who got the app.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -257,11 +222,10 @@ 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
|
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,
|
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 and packed into an MSIX, which is left in the artifacts of the run. The
|
||||||
attached. The version is taken from the tag alone — a tag shaped any other way
|
version is taken from the tag alone — a tag shaped any other way stops the run
|
||||||
stops the run right at the start. The package version ends up as `1.2.3.0`: the
|
right at the start. The package version ends up as `1.2.3.0`: the Store takes
|
||||||
Store takes four numbers and keeps the last one for itself, so the tag has no
|
four numbers and keeps the last one for itself, so the tag has no say in it.
|
||||||
say in it.
|
|
||||||
|
|
||||||
Both pipelines ask for a Windows runner labelled `windows-x64` with the .NET 10
|
Both pipelines ask for a Windows runner labelled `windows-x64` with the .NET 10
|
||||||
SDK and git-lfs on it. The checkout pulls LFS files: without them the icon is a
|
SDK and git-lfs on it. The checkout pulls LFS files: without them the icon is a
|
||||||
@@ -271,9 +235,18 @@ 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
|
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.
|
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
|
The package goes to the Store and nowhere else: it is unsigned, and Partner
|
||||||
comes from repository variables and falls back to the defaults of the script when
|
Center puts its own signature on it. So the run leaves it in the artifacts under
|
||||||
unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`.
|
the name `msix-1.2.3.0`, where whoever uploads it picks it up by hand; nothing
|
||||||
|
is attached to the release, which Gitea makes for the tag by itself and which
|
||||||
|
carries the tag alone. An unsigned package hanging off a release would look like
|
||||||
|
something to install and install nowhere — see the section on updates.
|
||||||
|
|
||||||
|
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`. Together the identity and the publisher decide
|
||||||
|
the family name of the package, so both have to stay as they are from version to
|
||||||
|
version, or the Store takes the next one for a different app.
|
||||||
|
|
||||||
## Building the MSIX package
|
## Building the MSIX package
|
||||||
|
|
||||||
@@ -286,17 +259,22 @@ to submitting for certification — is written up in
|
|||||||
# One-off: draw the logos and the exe icon (already committed, rerun after edits)
|
# One-off: draw the logos and the exe icon (already committed, rerun after edits)
|
||||||
powershell -File Packaging\New-Assets.ps1
|
powershell -File Packaging\New-Assets.ps1
|
||||||
|
|
||||||
# A check on your own machine: your architecture alone
|
# A check on your own machine
|
||||||
powershell -File Packaging\build-msix.ps1 -Architectures x64
|
powershell -File Packaging\build-msix.ps1
|
||||||
|
|
||||||
# For Partner Center — the identity is the one reserved there
|
# For Partner Center — the identity is the one reserved there
|
||||||
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
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-<version>.msixbundle` covering x64
|
The result is `artifacts\packages\CursorLang-<version>-x64.msix`. Upload it to
|
||||||
and arm64; next to it lie the packages of single architectures. Upload the bundle
|
Partner Center as it is.
|
||||||
to Partner Center as it is.
|
|
||||||
|
Only x64 is built. An arm64 build would double the size of every release for the
|
||||||
|
sake of machines that run the x64 one under emulation anyway.
|
||||||
|
|
||||||
|
Nothing here is signed: the Store signs the package itself, and for installing it
|
||||||
|
on this machine the layout is registered instead — see `-Install` below.
|
||||||
|
|
||||||
The app ships with its own copy of .NET: Windows does not include .NET 10, and
|
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.
|
MSIX cannot install a runtime as a package dependency.
|
||||||
@@ -304,7 +282,7 @@ MSIX cannot install a runtime as a package dependency.
|
|||||||
To see the package working on this machine, build it with `-Install`:
|
To see the package working on this machine, build it with `-Install`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
|
pwsh -File Packaging\build-msix.ps1 -Install
|
||||||
```
|
```
|
||||||
|
|
||||||
The application then shows up in the Start menu like any installed one. What
|
The application then shows up in the Start menu like any installed one. What
|
||||||
@@ -318,5 +296,41 @@ the Store answers to the same name and is left alone.
|
|||||||
To remove it by hand:
|
To remove it by hand:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName
|
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Building the installer
|
||||||
|
|
||||||
|
The same application also comes as an ordinary MSI, for handing round outside the
|
||||||
|
Store — to try it out before the Store has passed judgement, or if it never does.
|
||||||
|
Nothing has to be installed beyond the .NET SDK: WiX comes from a NuGet package,
|
||||||
|
the same way `makeappx` does.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Build it and run it afterwards, to see what a user sees
|
||||||
|
pwsh -File Packaging\build-installer.ps1 -Install
|
||||||
|
|
||||||
|
# Everything a release needs
|
||||||
|
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is `artifacts\installers\CursorLang-<version>-x64.msi`. Everything
|
||||||
|
travels inside the .msi; there is no cabinet to send alongside it.
|
||||||
|
|
||||||
|
It installs for the current user alone, into `%LOCALAPPDATA%\Programs\CursorLang`,
|
||||||
|
and so asks for no administrator rights and no consent dialog. Uninstalling goes
|
||||||
|
through Apps in Settings like any other program and takes the startup entry with
|
||||||
|
it — otherwise Windows would go on listing an application that is no longer there.
|
||||||
|
|
||||||
|
Nobody signs the installer, so Windows warns about an unknown publisher and the
|
||||||
|
user has to insist. Buying a certificate would not silence it at once either:
|
||||||
|
SmartScreen goes by reputation, and a fresh certificate has none until enough
|
||||||
|
people have installed the application.
|
||||||
|
|
||||||
|
Two things about the WiX project are worth knowing before touching it. It pins
|
||||||
|
WiX 5 rather than the current 7: from version 6 the toolset asks every build to
|
||||||
|
accept the Open Source Maintenance Fee licence — free below $10,000 of yearly
|
||||||
|
revenue, but a decision for a person rather than for a build script. And it turns
|
||||||
|
off three ICE validation rules: MSI still assumes an installation for the whole
|
||||||
|
machine, and installing into the user's own profile trips rules that describe
|
||||||
|
exactly what was intended here.
|
||||||
|
|||||||
Reference in New Issue
Block a user