modified release pipeline #4
@@ -2,6 +2,13 @@
|
||||
# packs the MSIX with the version taken from the tag — three numbers of the tag
|
||||
# and a zero the Store keeps for itself.
|
||||
#
|
||||
# The package goes to the Store and nowhere else, so it leaves the run as an
|
||||
# artifact: someone picks it up and uploads it to Partner Center, which puts its
|
||||
# own signature on it. Nothing is signed here and nothing is attached to the
|
||||
# release — a publicly trusted code signing certificate is not to be had, and an
|
||||
# unsigned package would look like something to install and install nowhere.
|
||||
# Gitea makes the release for the tag itself, and it carries the tag alone.
|
||||
#
|
||||
# The same requirements to the runner as in pull-request.yml apply: Windows, the
|
||||
# .NET 10 SDK and an interactive desktop session for the tests. makeappx comes
|
||||
# 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
|
||||
"version=$($tag.Substring(1)).0" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
|
||||
# The installer answers to nobody about a fourth number and takes the
|
||||
# tag as it is
|
||||
"plain=$($tag.Substring(1))" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
|
||||
- name: Show the toolchain
|
||||
run: dotnet --info
|
||||
|
||||
@@ -121,15 +132,32 @@ jobs:
|
||||
|
||||
./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
|
||||
with:
|
||||
name: msix-${{ steps.version.outputs.version }}
|
||||
path: artifacts/packages/
|
||||
if-no-files-found: error
|
||||
|
||||
# Gitea creates a release of its own for a pushed tag, so the release is
|
||||
# looked up first and only made when it is not there
|
||||
# The .wixpdb next to each installer is left out on purpose: it is of use
|
||||
# only when something has to be traced back to the WiX source
|
||||
- name: Keep the installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-${{ steps.version.outputs.plain }}
|
||||
path: artifacts/installers/*.msi
|
||||
if-no-files-found: error
|
||||
|
||||
# Only the installers go into the release. The MSIX stays in the artifacts
|
||||
# of the run: unsigned, it installs nowhere, and its one destination is
|
||||
# Partner Center
|
||||
- name: Publish the release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -145,6 +173,8 @@ jobs:
|
||||
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
|
||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||
|
||||
# Gitea makes a release of its own for a pushed tag, so the release is
|
||||
# looked up first and only made when it is not there
|
||||
$release = $null
|
||||
try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { }
|
||||
|
||||
@@ -153,7 +183,7 @@ jobs:
|
||||
$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
|
||||
# the same name is dropped, otherwise the upload is refused
|
||||
$existing = $release.assets | Where-Object { $_.name -eq $file.Name }
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>CursorLang</Product>
|
||||
<Company>Aleksandr Neychev</Company>
|
||||
<Company>Aleksandr Neichev</Company>
|
||||
<Description>Shows the keyboard layout at the cursor</Description>
|
||||
<Copyright>Copyright (c) 2026</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -79,14 +79,15 @@ public sealed class StringsTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The version of an update is put into the string by the app, so the place
|
||||
/// for it has to be there in both languages.
|
||||
/// The version is put into the title by the app, so the place for it has to
|
||||
/// be there in both languages: the title is the only place it is shown, and a
|
||||
/// translation without the placeholder would quietly drop it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_string_about_an_available_update_has_room_for_the_version()
|
||||
public void The_title_of_the_window_has_room_for_the_version()
|
||||
{
|
||||
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", English), StringComparison.Ordinal);
|
||||
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal);
|
||||
Assert.Contains("{0}", Resources.GetString("SettingsTitle", English), StringComparison.Ordinal);
|
||||
Assert.Contains("{0}", Resources.GetString("SettingsTitle", Russian), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static TheoryData<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>
|
||||
<AssemblyName>CursorLang.Core</AssemblyName>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>CursorLang</Product>
|
||||
<Company>Aleksandr Neychev</Company>
|
||||
<Description>Shared part of CursorLang: models, settings, layout tracking, updates</Description>
|
||||
<Company>Aleksandr Neichev</Company>
|
||||
<Description>Shared part of CursorLang: models, settings, layout tracking</Description>
|
||||
<Copyright>Copyright (c) 2026</Copyright>
|
||||
</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>
|
||||
</resheader>
|
||||
<data name="SettingsTitle" xml:space="preserve">
|
||||
<value>CursorLang — Settings</value>
|
||||
<value>CursorLang {0} — Settings</value>
|
||||
</data>
|
||||
<data name="TrayMenuSettings" xml:space="preserve">
|
||||
<value>Settings</value>
|
||||
@@ -211,46 +211,4 @@
|
||||
<data name="StartupLockedHint" xml:space="preserve">
|
||||
<value>Startup for this app is now controlled by Windows: Settings — Apps — Startup.</value>
|
||||
</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>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="SettingsTitle" xml:space="preserve">
|
||||
<value>CursorLang — Настройки</value>
|
||||
<value>CursorLang {0} — Настройки</value>
|
||||
</data>
|
||||
<data name="TrayMenuSettings" xml:space="preserve">
|
||||
<value>Настройки</value>
|
||||
@@ -211,46 +211,4 @@
|
||||
<data name="StartupLockedHint" xml:space="preserve">
|
||||
<value>Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка».</value>
|
||||
</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>
|
||||
|
||||
@@ -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(ILocalizationService))]
|
||||
[InlineData(typeof(IStartupService))]
|
||||
[InlineData(typeof(IUpdateService))]
|
||||
[InlineData(typeof(UpdateOptions))]
|
||||
[InlineData(typeof(Version))]
|
||||
[InlineData(typeof(SettingsViewModel))]
|
||||
[InlineData(typeof(UpdateViewModel))]
|
||||
[InlineData(typeof(MainWindow))]
|
||||
[InlineData(typeof(AppSettings))]
|
||||
public void Everything_the_window_needs_is_declared_in_the_container(Type service)
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Infrastructure;
|
||||
|
||||
/// <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>
|
||||
/// A theme that paints nothing and only remembers the windows attached to it.
|
||||
/// </summary>
|
||||
|
||||
@@ -285,11 +285,12 @@ public sealed class SettingsViewModelTests
|
||||
private static SettingsViewModel Create(
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null,
|
||||
IStartupService? startup = null) =>
|
||||
IStartupService? startup = null,
|
||||
Version? version = null) =>
|
||||
new(settings ?? new AppSettings(),
|
||||
localization ?? new FakeLocalizationService(),
|
||||
startup ?? new FakeStartupService(),
|
||||
Fake.Updates());
|
||||
version ?? new Version(1, 0, 0, 0));
|
||||
|
||||
// The setting travels to Windows without being awaited: the window must not freeze
|
||||
private static async Task WaitForStartupRequests(FakeStartupService startup, int count)
|
||||
|
||||
@@ -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]
|
||||
public void The_updates_are_checked_by_the_button_in_the_window()
|
||||
public void The_title_of_the_window_shows_the_version()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel section = CreateUpdates(updates);
|
||||
using SettingsViewModel viewModel = CreateViewModel(updates: section);
|
||||
var localization = new FakeLocalizationService();
|
||||
localization.Strings["SettingsTitle"] = "CursorLang {0} — Settings";
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand"));
|
||||
using SettingsViewModel viewModel = CreateViewModel(
|
||||
localization: localization,
|
||||
version: new Version(1, 2, 3, 0));
|
||||
|
||||
Assert.True(check.IsVisible);
|
||||
check.Command.Execute(null);
|
||||
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// An app installed from the Store is updated by the Store
|
||||
[Fact]
|
||||
public void An_app_that_updates_itself_elsewhere_shows_no_updates_section()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using UpdateViewModel section = CreateUpdates(new FakeUpdateService { IsSupported = false });
|
||||
using SettingsViewModel viewModel = CreateViewModel(updates: section);
|
||||
|
||||
Open(viewModel, window =>
|
||||
Assert.All(FindButtonsBoundTo(window, "Updates.CheckCommand"), button =>
|
||||
Assert.False(button.IsVisible)));
|
||||
Open(viewModel, window => Assert.Equal("CursorLang 1.2.3 — Settings", window.Title));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -486,11 +467,11 @@ public sealed class MainWindowTests
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null,
|
||||
IStartupService? startup = null,
|
||||
UpdateViewModel? updates = null) =>
|
||||
Version? version = null) =>
|
||||
new(settings ?? new AppSettings(),
|
||||
localization ?? new FakeLocalizationService(),
|
||||
startup ?? new FakeStartupService(),
|
||||
updates ?? Fake.Updates());
|
||||
version ?? new Version(1, 0, 0, 0));
|
||||
|
||||
private static void Open(SettingsViewModel viewModel, Action<MainWindow> 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) =>
|
||||
FindAll<Button>(root).Where(button =>
|
||||
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
|
||||
|
||||
@@ -55,7 +55,6 @@ public partial class App : Application
|
||||
MainWindow.Show();
|
||||
|
||||
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
|
||||
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
@@ -73,7 +72,7 @@ public partial class App : Application
|
||||
|
||||
internal static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(new UpdateOptions());
|
||||
services.AddSingleton(AppVersion.Current);
|
||||
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
|
||||
@@ -85,9 +84,7 @@ public partial class App : Application
|
||||
|
||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||
services.AddSingleton<IStartupService, StartupService>();
|
||||
services.AddSingleton<IUpdateService, UpdateService>();
|
||||
|
||||
services.AddSingleton<UpdateViewModel>();
|
||||
services.AddSingleton<SettingsViewModel>();
|
||||
|
||||
services.AddSingleton<MainWindow>();
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>CursorLang</Product>
|
||||
<Company>Aleksandr Neychev</Company>
|
||||
<Company>Aleksandr Neichev</Company>
|
||||
<Description>Settings window of CursorLang</Description>
|
||||
<Copyright>Copyright (c) 2026</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -65,6 +65,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
];
|
||||
|
||||
private readonly IStartupService _startup;
|
||||
private readonly Version _version;
|
||||
|
||||
private StartupState _startupState = StartupState.Unavailable;
|
||||
|
||||
@@ -72,12 +73,12 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
AppSettings settings,
|
||||
ILocalizationService localization,
|
||||
IStartupService startup,
|
||||
UpdateViewModel updates)
|
||||
Version version)
|
||||
{
|
||||
Settings = settings;
|
||||
Localization = localization;
|
||||
Updates = updates;
|
||||
_startup = startup;
|
||||
_version = version;
|
||||
|
||||
// The interface language is a setting like any other and is stored in the same place
|
||||
Localization.CurrentLanguage = settings.Language;
|
||||
@@ -95,8 +96,23 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
|
||||
public ILocalizationService Localization { get; }
|
||||
|
||||
/// <summary>The updates section: it has a state and commands of its own.</summary>
|
||||
public UpdateViewModel Updates { get; }
|
||||
/// <summary>
|
||||
/// 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;
|
||||
|
||||
@@ -188,6 +204,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(Title));
|
||||
Translate(Themes);
|
||||
Translate(PlacementModes);
|
||||
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"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
|
||||
Title="{Binding Localization[SettingsTitle]}"
|
||||
Title="{Binding Title}"
|
||||
Width="1000" SizeToContent="Height" MaxHeight="900"
|
||||
ResizeMode="CanMinimize"
|
||||
Background="{DynamicResource Theme.WindowBackground}"
|
||||
@@ -196,72 +196,6 @@
|
||||
</Grid>
|
||||
</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 Grid.Column="1" Margin="16,0,0,0">
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Navigation;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
|
||||
@@ -28,23 +25,4 @@ public partial class MainWindow : Window
|
||||
theme.Register(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>
|
||||
/// Interface strings without resources: the key comes back as is, tagged with the language.
|
||||
/// </summary>
|
||||
@@ -200,12 +131,20 @@ public sealed class FakeLocalizationService : ILocalizationService
|
||||
|
||||
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]
|
||||
{
|
||||
get
|
||||
{
|
||||
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
|
||||
packages folder. It is kept out of the build in CursorLang.sln for the same
|
||||
reason.
|
||||
|
||||
The installer needs nothing from here: WiX arrives with its own project, at
|
||||
Installer\CursorLang.wixproj.
|
||||
-->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
+45
-75
@@ -1,6 +1,6 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the CursorLang MSIX package for the Microsoft Store.
|
||||
Builds the CursorLang MSIX package.
|
||||
|
||||
.DESCRIPTION
|
||||
Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a
|
||||
@@ -8,7 +8,9 @@
|
||||
plain .NET SDK.
|
||||
|
||||
The package is handed to Partner Center as it comes out of here — the Store
|
||||
is where it gets everything else done to it.
|
||||
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
|
||||
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
|
||||
the package is made of rather than the package file.
|
||||
|
||||
.EXAMPLE
|
||||
# A check on your own machine: your architecture alone
|
||||
pwsh -File Packaging\build-msix.ps1 -Architectures x64
|
||||
|
||||
.EXAMPLE
|
||||
# Build and install in one go, to click through the application
|
||||
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
|
||||
pwsh -File Packaging\build-msix.ps1 -Install
|
||||
|
||||
.EXAMPLE
|
||||
# A build for the Store — the identity comes from Partner Center
|
||||
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
||||
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -42,11 +40,8 @@ param(
|
||||
[string] $Version = '1.0.0.0',
|
||||
|
||||
[string] $IdentityName = 'CursorLang',
|
||||
[string] $Publisher = 'CN=Aleksandr Neychev',
|
||||
[string] $PublisherDisplayName = 'Aleksandr Neychev',
|
||||
|
||||
[ValidateSet('x64', 'arm64')]
|
||||
[string[]] $Architectures = @('x64', 'arm64'),
|
||||
[string] $Publisher = 'CN=Aleksandr Neichev',
|
||||
[string] $PublisherDisplayName = 'Aleksandr Neichev',
|
||||
|
||||
[switch] $Install,
|
||||
|
||||
@@ -64,7 +59,14 @@ $assets = Join-Path $root 'Assets'
|
||||
$manifestTemplate = Join-Path $root 'AppxManifest.xml'
|
||||
$toolsProject = Join-Path $root 'Tools\SdkTools.csproj'
|
||||
|
||||
if (-not $OutputPath) { $OutputPath = Join-Path $repository 'artifacts' }
|
||||
if (-not $OutputPath) {
|
||||
$OutputPath = Join-Path $repository 'artifacts'
|
||||
} elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
|
||||
# Windows names the folder a package was registered from in full, and the
|
||||
# layout is looked for by that name below. A relative path would never be
|
||||
# found there, and the registration of a build gone by would be left behind
|
||||
$OutputPath = Join-Path (Get-Location).Path $OutputPath
|
||||
}
|
||||
$layoutRoot = Join-Path $OutputPath 'layout'
|
||||
$packagesPath = Join-Path $OutputPath 'packages'
|
||||
|
||||
@@ -77,9 +79,8 @@ if (-not (Test-Path $assets)) {
|
||||
}
|
||||
|
||||
if ($Install) {
|
||||
# Both things below are checked before the build rather than after it: the
|
||||
# build takes minutes, and neither of them gets any truer while it runs
|
||||
|
||||
# Checked before the build rather than after it: the build takes minutes, and
|
||||
# this does not get any truer while it runs
|
||||
$developerMode = Get-ItemPropertyValue `
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' `
|
||||
-Name 'AllowDevelopmentWithoutDevLicense' -ErrorAction SilentlyContinue
|
||||
@@ -87,12 +88,6 @@ if ($Install) {
|
||||
if ($developerMode -ne 1) {
|
||||
throw 'Installing needs developer mode: Settings - System - For developers - Developer mode. Without a signature Windows registers a package no other way.'
|
||||
}
|
||||
|
||||
$machineArchitecture = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
|
||||
|
||||
if ($Architectures -notcontains $machineArchitecture) {
|
||||
throw "This machine is $machineArchitecture, and that architecture is not being built. Add it to -Architectures, or drop -Install."
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Tool {
|
||||
@@ -189,71 +184,46 @@ foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) {
|
||||
Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null
|
||||
|
||||
$built = @()
|
||||
Write-Host 'Building...' -ForegroundColor Cyan
|
||||
|
||||
foreach ($architecture in $Architectures) {
|
||||
Write-Host "Building $architecture..." -ForegroundColor Cyan
|
||||
$layout = Join-Path $layoutRoot 'x64'
|
||||
|
||||
$layout = Join-Path $layoutRoot $architecture
|
||||
|
||||
foreach ($half in @($agentProject, $settingsProject)) {
|
||||
Invoke-Tool -Path 'dotnet' -Arguments @(
|
||||
'publish', $half,
|
||||
'--configuration', 'Release',
|
||||
'--runtime', "win-$architecture",
|
||||
'--self-contained', 'true',
|
||||
"-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))",
|
||||
'--output', $layout,
|
||||
'--nologo'
|
||||
)
|
||||
}
|
||||
|
||||
# Debug symbols have no place in the package: they take up room, the user
|
||||
# has no use for them, and for crash reports the Store takes them separately
|
||||
Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force
|
||||
|
||||
Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
|
||||
|
||||
$manifest = (Get-Content $manifestTemplate -Raw).
|
||||
Replace('{IdentityName}', $IdentityName).
|
||||
Replace('{Publisher}', $Publisher).
|
||||
Replace('{PublisherDisplayName}', $PublisherDisplayName).
|
||||
Replace('{Version}', $Version).
|
||||
Replace('{Architecture}', $architecture)
|
||||
|
||||
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
|
||||
|
||||
$package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix"
|
||||
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package)
|
||||
|
||||
$built += $package
|
||||
foreach ($half in @($agentProject, $settingsProject)) {
|
||||
Invoke-Tool -Path 'dotnet' -Arguments @(
|
||||
'publish', $half,
|
||||
'--configuration', 'Release',
|
||||
'--runtime', 'win-x64',
|
||||
'--self-contained', 'true',
|
||||
"-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))",
|
||||
'--output', $layout,
|
||||
'--nologo'
|
||||
)
|
||||
}
|
||||
|
||||
$result = $built[0]
|
||||
# Debug symbols have no place in the package: they take up room, the user
|
||||
# has no use for them, and for crash reports the Store takes them separately
|
||||
Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force
|
||||
|
||||
if ($built.Count -gt 1) {
|
||||
Write-Host 'Bringing the architectures into one package...' -ForegroundColor Cyan
|
||||
Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
|
||||
|
||||
# makeappx bundle takes everything from a folder, so the separate packages
|
||||
# are gathered into one of their own first — otherwise the results of
|
||||
# earlier builds would end up in the bundle
|
||||
$bundleInput = Join-Path $OutputPath 'bundle'
|
||||
Remove-Item $bundleInput -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null
|
||||
$built | ForEach-Object { Copy-Item $_ -Destination $bundleInput }
|
||||
$manifest = (Get-Content $manifestTemplate -Raw).
|
||||
Replace('{IdentityName}', $IdentityName).
|
||||
Replace('{Publisher}', $Publisher).
|
||||
Replace('{PublisherDisplayName}', $PublisherDisplayName).
|
||||
Replace('{Version}', $Version).
|
||||
Replace('{Architecture}', 'x64')
|
||||
|
||||
$result = Join-Path $packagesPath "CursorLang-$Version.msixbundle"
|
||||
Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version)
|
||||
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
|
||||
|
||||
Remove-Item $bundleInput -Recurse -Force
|
||||
}
|
||||
$result = Join-Path $packagesPath "CursorLang-$Version-x64.msix"
|
||||
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $result)
|
||||
|
||||
if ($Install) {
|
||||
Write-Host "Installing the $machineArchitecture build..." -ForegroundColor Cyan
|
||||
Write-Host 'Installing...' -ForegroundColor Cyan
|
||||
|
||||
# The layout is registered rather than the package file: the two hold the
|
||||
# same thing, but a package file Windows only installs when it is signed
|
||||
Add-AppxPackage -Register (Join-Path $layoutRoot "$machineArchitecture\AppxManifest.xml")
|
||||
Add-AppxPackage -Register (Join-Path $layout 'AppxManifest.xml')
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
|
||||
+80
-66
@@ -81,8 +81,8 @@ MSIX всегда выполняются в контексте вошедшег
|
||||
открытии окна.
|
||||
|
||||
`CursorLang.Core.dll` — общее для обоих: модели, файл настроек, слежение за
|
||||
раскладкой, обновления. Без UI, и так должно остаться: всё, что попадёт туда,
|
||||
попадёт и в фоновый процесс.
|
||||
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
|
||||
в фоновый процесс.
|
||||
|
||||
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный
|
||||
файл, который одним движением встаёт на место, и посылает агенту
|
||||
@@ -167,56 +167,21 @@ MSIX всегда выполняются в контексте вошедшег
|
||||
|
||||
## Обновления
|
||||
|
||||
Проверка выполняется при открытии окна настроек, а не при включении машины:
|
||||
фоновая половина в сеть больше не ходит вовсе, да и показать ответ ей нечем.
|
||||
Настройка в окне так и написана.
|
||||
Приложение обновляет Store, а само приложение об этом не заботится: раздела
|
||||
обновлений в окне нет, запросов в сеть нет и кода для них тоже нет.
|
||||
|
||||
Приложение ищет новые версии среди выпусков собственного репозитория. Выпуск
|
||||
годится, если его тег — это просто версия (`v1.2.3` или `1.2.3`) и к нему
|
||||
приложен пакет MSIX. Тег, в котором есть что-то ещё, — в том числе `v1.2.3-beta`
|
||||
— пропускается: предварительную версию берут намеренно, приложение её не
|
||||
предлагает.
|
||||
Дело не во вкусе, а в цене подписи. MSIX Windows установит только тогда, когда
|
||||
доверяет подписи на нём, а публично доверенный сертификат для подписи кода
|
||||
оказался недосягаем: удостоверяющие центры, которые их продают, здесь его не
|
||||
выдают, а те, что держали бы ключ в облачном HSM, — тем более, а сертификат на
|
||||
USB-токене сюда не привезти. Без подписи пакет установится только на машине в
|
||||
режиме разработчика, значит выкладывать в выпуск нечего и искать обновления
|
||||
негде. Store подписывает пакет своим сертификатом и обновляет приложение сам —
|
||||
на этом вопрос и закрыт.
|
||||
|
||||
Из приложенных файлов предпочитается `.msixbundle` — он несёт обе архитектуры.
|
||||
Если его нет, берётся пакет, в имени которого стоит архитектура этой машины:
|
||||
`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`, а не хранится в исходниках: секрет, встроенный в
|
||||
сборку, — это секрет, отданный всем, кто эту сборку получил.
|
||||
Версия работающего приложения стоит в заголовке окна настроек:
|
||||
`CursorLang 1.2.3 — Настройки`. Больше её в интерфейсе нигде нет, и стоит она
|
||||
там для того, чтобы её можно было назвать в сообщении об ошибке.
|
||||
|
||||
## Тесты
|
||||
|
||||
@@ -262,11 +227,11 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
|
||||
|
||||
Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос
|
||||
на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3`
|
||||
проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом
|
||||
с приложенными пакетами. Номер версии берётся только из тега — тег любого
|
||||
другого вида останавливает прогон в самом начале. Версия пакета получается
|
||||
`1.2.3.0`: Store принимает четыре числа и последнее оставляет себе, так что тег
|
||||
на него не влияет.
|
||||
проект собирается, проходит тесты и пакуется в MSIX, который остаётся
|
||||
в артефактах прогона. Номер версии берётся только из тега — тег любого другого
|
||||
вида останавливает прогон в самом начале. Версия пакета получается `1.2.3.0`:
|
||||
Store принимает четыре числа и последнее оставляет себе, так что тег на него не
|
||||
влияет.
|
||||
|
||||
Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём —
|
||||
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
|
||||
@@ -276,10 +241,18 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
|
||||
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
|
||||
сеансе, пропускают себя: показать окно там негде.
|
||||
|
||||
Пакет, который несёт релиз, загружается в Partner Center как есть. Identity
|
||||
берётся из переменных репозитория, а если те не заданы — из значений по
|
||||
Пакет идёт в Store и больше никуда: он не подписан, подпись на него ставит сам
|
||||
Partner Center. Поэтому прогон оставляет его в артефактах под именем
|
||||
`msix-1.2.3.0`, откуда его забирают и загружают руками; к релизу не прикладывается
|
||||
ничего — релиз по тегу Gitea заводит сама, и в нём один только тег. Неподписанный
|
||||
пакет, висящий в релизе, выглядел бы как то, что можно установить, и не
|
||||
устанавливался бы нигде — см. раздел об обновлениях.
|
||||
|
||||
Identity берётся из переменных репозитория, а если те не заданы — из значений по
|
||||
умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
|
||||
и `MSIX_PUBLISHER_DISPLAY_NAME`.
|
||||
и `MSIX_PUBLISHER_DISPLAY_NAME`. Вместе identity и publisher задают family name
|
||||
пакета, поэтому от версии к версии оба должны оставаться прежними — иначе Store
|
||||
примет следующую за другое приложение.
|
||||
|
||||
## Сборка пакета MSIX
|
||||
|
||||
@@ -292,17 +265,22 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
|
||||
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
|
||||
powershell -File Packaging\New-Assets.ps1
|
||||
|
||||
# Проверка на своей машине: только своя архитектура
|
||||
powershell -File Packaging\build-msix.ps1 -Architectures x64
|
||||
# Проверка на своей машине
|
||||
powershell -File Packaging\build-msix.ps1
|
||||
|
||||
# Для Partner Center — identity та, что зарезервирована там
|
||||
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
||||
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
```
|
||||
|
||||
Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64
|
||||
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
|
||||
Center как есть.
|
||||
Результат — `artifacts\packages\CursorLang-<версия>-x64.msix`. Он загружается в
|
||||
Partner Center как есть.
|
||||
|
||||
Собирается только x64. Сборка под arm64 удвоила бы вес каждого релиза ради
|
||||
машин, которые и так выполняют x64 через эмуляцию.
|
||||
|
||||
Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки
|
||||
на эту машину вместо пакета регистрируется layout — см. `-Install` ниже.
|
||||
|
||||
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
|
||||
MSIX не может установить среду выполнения как зависимость пакета.
|
||||
@@ -310,7 +288,7 @@ MSIX не может установить среду выполнения как
|
||||
Чтобы посмотреть, как пакет работает на этой машине, соберите его с `-Install`:
|
||||
|
||||
```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
|
||||
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.
|
||||
|
||||
`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
|
||||
there lands in the background process.
|
||||
the layout tracking. No UI, and it must stay that way — whatever lands there lands
|
||||
in the background process.
|
||||
|
||||
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
|
||||
@@ -162,57 +162,22 @@ after a switch of the mode, and that is a question asked once.
|
||||
|
||||
## Updates
|
||||
|
||||
The check runs when the settings window is opened, not when the machine is
|
||||
switched on: the background half no longer goes to the network at all, and there
|
||||
would be nothing in it to show the answer. The setting in the window says as much.
|
||||
The Store updates the app, and the app itself does nothing about it: there is no
|
||||
updates section in the window, no request to the network and no code for either.
|
||||
|
||||
The app looks for new versions among the releases of its own repository. A
|
||||
release counts when its tag is a plain version — `v1.2.3` or `1.2.3` — and an
|
||||
MSIX package is attached to it. A tag with anything else in it, `v1.2.3-beta`
|
||||
among them, is passed over: a pre-release version is asked for on purpose, not
|
||||
offered by the app.
|
||||
That is not a matter of taste but of what a signature costs. Windows installs an
|
||||
MSIX only when it trusts the signature on it, and a publicly trusted code signing
|
||||
certificate has turned out to be beyond reach — the certificate authorities that
|
||||
sell them will not issue one here, and those that would keep the key in a cloud
|
||||
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
|
||||
architectures. Failing that, the package whose name holds the architecture of
|
||||
this machine is taken: `CursorLang-1.2.3.0-x64.msix`. Those are the names
|
||||
`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.
|
||||
The version of the running app is in the title of the settings window:
|
||||
`CursorLang 1.2.3 — Settings`. It is nowhere else in the interface, and it is
|
||||
there so that a bug report can name it.
|
||||
|
||||
## 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
|
||||
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
|
||||
attached. The version is taken from the tag alone — a tag shaped any other way
|
||||
stops the run right at the start. The package version ends up as `1.2.3.0`: the
|
||||
Store takes four numbers and keeps the last one for itself, so the tag has no
|
||||
say in it.
|
||||
tested and packed into an MSIX, which is left in the artifacts of the run. The
|
||||
version is taken from the tag alone — a tag shaped any other way stops the run
|
||||
right at the start. The package version ends up as `1.2.3.0`: the Store takes
|
||||
four numbers and keeps the last one for itself, so the tag has no say in it.
|
||||
|
||||
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
|
||||
@@ -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
|
||||
service in session 0, where there is no desktop to show a window on.
|
||||
|
||||
The package the release carries goes to Partner Center as it is. The identity
|
||||
comes from repository variables and falls back to the defaults of the script when
|
||||
unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`.
|
||||
The package goes to the Store and nowhere else: it is unsigned, and Partner
|
||||
Center puts its own signature on it. So the run leaves it in the artifacts under
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
powershell -File Packaging\New-Assets.ps1
|
||||
|
||||
# A check on your own machine: your architecture alone
|
||||
powershell -File Packaging\build-msix.ps1 -Architectures x64
|
||||
# A check on your own machine
|
||||
powershell -File Packaging\build-msix.ps1
|
||||
|
||||
# For Partner Center — the identity is the one reserved there
|
||||
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
||||
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
```
|
||||
|
||||
The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64
|
||||
and arm64; next to it lie the packages of single architectures. Upload the bundle
|
||||
to Partner Center as it is.
|
||||
The result is `artifacts\packages\CursorLang-<version>-x64.msix`. Upload it to
|
||||
Partner Center as it is.
|
||||
|
||||
Only x64 is built. An arm64 build would double the size of every release for the
|
||||
sake of machines that run the x64 one under emulation anyway.
|
||||
|
||||
Nothing here is signed: the Store signs the package itself, and for installing it
|
||||
on this machine the layout is registered instead — see `-Install` below.
|
||||
|
||||
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.
|
||||
@@ -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`:
|
||||
|
||||
```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
|
||||
@@ -318,5 +296,41 @@ the Store answers to the same name and is left alone.
|
||||
To remove it by hand:
|
||||
|
||||
```powershell
|
||||
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName
|
||||
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
|
||||
```
|
||||
|
||||
## 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