From e5180c77564e2048fd8eed76d9d261f6a5658ece Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 2 Dec 2024 10:18:37 +0700 Subject: [PATCH 01/19] create api to redirect to the latest GitHub release of fw lite --- .../Controllers/FwLiteReleaseController.cs | 250 ++++++++++++++++++ backend/LexBoxApi/LexBoxApi.csproj | 1 + backend/LexBoxApi/Program.cs | 3 + 3 files changed, 254 insertions(+) create mode 100644 backend/LexBoxApi/Controllers/FwLiteReleaseController.cs diff --git a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs new file mode 100644 index 000000000..b78af9423 --- /dev/null +++ b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs @@ -0,0 +1,250 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Hybrid; + +namespace LexBoxApi.Controllers; + +[ApiController] +[Route("/api/fwlite-release")] +public class FwLiteReleaseController(IHttpClientFactory factory, HybridCache cache) : ControllerBase +{ + private const string GithubLatestRelease = "GithubLatestRelease"; + + [HttpGet("latest")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Latest() + { + var latestReleaseUrl = await cache.GetOrCreateAsync(GithubLatestRelease, + LatestVersionFromGithub, + new HybridCacheEntryOptions() { Expiration = TimeSpan.FromDays(1) }); + if (latestReleaseUrl is null) return NotFound(); + return Redirect(latestReleaseUrl); + } + + private async ValueTask LatestVersionFromGithub(CancellationToken token) + { + var response = await factory.CreateClient("Github") + .SendAsync(new HttpRequestMessage(HttpMethod.Get, + "https://api.github.com/repos/sillsdev/languageforge-lexbox/releases") + { + Headers = + { + { "X-GitHub-Api-Version", " 2022-11-28" }, + { "Accept", "application/vnd.github+json" }, + { "User-Agent", "Lexbox-Release-Endpoint" } + } + }, + token); + if (!response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(token); + throw new Exception($"Failed to get latest release from github: {response.StatusCode} {responseContent}"); + } + response.EnsureSuccessStatusCode(); + var releases = await response.Content.ReadFromJsonAsync(token); + if (releases is null) return null; + foreach (var release in releases) + { + if (release is { Draft: true } or { Prerelease: true }) + { + continue; + } + var msixBundle = release.Assets.FirstOrDefault(a => a.Name.EndsWith(".msixbundle", StringComparison.InvariantCultureIgnoreCase)); + if (msixBundle is not null) + { + return msixBundle.BrowserDownloadUrl; + } + } + return null; + } + + [HttpPost("new-release")] + [AllowAnonymous] + public async Task NewRelease() + { + await cache.RemoveAsync(GithubLatestRelease); + return Ok(); + } +} + +/// +/// A release. +/// +public class Release +{ + [JsonPropertyName("assets")] + public required ReleaseAsset[] Assets { get; set; } + + [JsonPropertyName("assets_url")] + public string? AssetsUrl { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("body_html")] + public string? BodyHtml { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("body_text")] + public string? BodyText { get; set; } + + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; set; } + + /// + /// The URL of the release discussion. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("discussion_url")] + public string? DiscussionUrl { get; set; } + + /// + /// true to create a draft (unpublished) release, false to create a published one. + /// + [JsonPropertyName("draft")] + public bool Draft { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mentions_count")] + public long? MentionsCount { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("node_id")] + public string? NodeId { get; set; } + + /// + /// Whether to identify the release as a prerelease or a full release. + /// + [JsonPropertyName("prerelease")] + public bool Prerelease { get; set; } + + [JsonPropertyName("published_at")] + public DateTimeOffset? PublishedAt { get; set; } + + /// + /// The name of the tag. + /// + [JsonPropertyName("tag_name")] + public string? TagName { get; set; } + + [JsonPropertyName("tarball_url")] + public string? TarballUrl { get; set; } + + /// + /// Specifies the commitish value that determines where the Git tag is created from. + /// + [JsonPropertyName("target_commitish")] + public string? TargetCommitish { get; set; } + + [JsonPropertyName("upload_url")] + public string? UploadUrl { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("zipball_url")] + public string? ZipballUrl { get; set; } +} + +/// +/// Data related to a release. +/// +public class ReleaseAsset +{ + [JsonPropertyName("browser_download_url")] + public required string BrowserDownloadUrl { get; set; } + + [JsonPropertyName("content_type")] + public string? ContentType { get; set; } + + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonPropertyName("download_count")] + public long DownloadCount { get; set; } + + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("label")] + public string? Label { get; set; } + + /// + /// The file name of the asset. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("node_id")] + public string? NodeId { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } + + /// + /// State of the release asset. + /// + [JsonPropertyName("state")] + public State State { get; set; } + + [JsonPropertyName("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } + + + [JsonPropertyName("url")] + public string? Url { get; set; } +} + +/// +/// State of the release asset. +/// +[JsonConverter(typeof(StateConverter))] +public enum State { Open, Uploaded }; + + +internal class StateConverter : JsonConverter +{ + public override bool CanConvert(Type t) => t == typeof(State); + + public override State Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "open": + return State.Open; + case "uploaded": + return State.Uploaded; + } + + throw new Exception("Cannot unmarshal type State"); + } + + public override void Write(Utf8JsonWriter writer, State value, JsonSerializerOptions options) + { + switch (value) + { + case State.Open: + JsonSerializer.Serialize(writer, "open", options); + return; + case State.Uploaded: + JsonSerializer.Serialize(writer, "uploaded", options); + return; + } + + throw new Exception("Cannot marshal type State"); + } + +} diff --git a/backend/LexBoxApi/LexBoxApi.csproj b/backend/LexBoxApi/LexBoxApi.csproj index 638ba148e..a2cfe8e59 100644 --- a/backend/LexBoxApi/LexBoxApi.csproj +++ b/backend/LexBoxApi/LexBoxApi.csproj @@ -30,6 +30,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 76c03493f..097494d30 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -83,6 +83,9 @@ """, }); }); +#pragma warning disable EXTEXP0018 +builder.Services.AddHybridCache(); +#pragma warning restore EXTEXP0018 builder.Services.AddHealthChecks(); //in prod the exception handler middleware adds the exception feature, but in dev we need to do it manually builder.Services.AddSingleton(); From 146856bdac878e4b88cc4c824e8de95ca93ca4fe Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 2 Dec 2024 10:18:54 +0700 Subject: [PATCH 02/19] invalidate lexbox release cache when a new version is released --- .github/workflows/fw-lite.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index f36d825e6..97a015d39 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -258,4 +258,9 @@ jobs: fw-lite-msix/* fw-lite-portable.zip fw-lite-local-web-app-linux.zip + - name: Invalidate Lexbox Release endpoint + continue-on-error: true + run: | + sleep 10 + curl -X POST https://lexbox.org/api/api/fwlite-release/new-release From 2c8dfae023518d467a53517f398824693f3e5d25 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 2 Dec 2024 11:27:03 +0700 Subject: [PATCH 03/19] implement api to check for the latest version and queue an update --- backend/FwLite/FwLiteDesktop/App.xaml.cs | 11 ++- .../FwLite/FwLiteDesktop/AppUpdateService.cs | 82 +++++++++++++++++++ backend/FwLite/FwLiteDesktop/AppVersion.cs | 9 ++ .../FwLiteDesktop/FwLiteDesktopKernel.cs | 5 ++ .../Properties/launchSettings.json | 3 +- .../Controllers/FwLiteReleaseController.cs | 37 +++++++-- backend/LexCore/Entities/FwLiteRelease.cs | 3 + 7 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 backend/FwLite/FwLiteDesktop/AppUpdateService.cs create mode 100644 backend/FwLite/FwLiteDesktop/AppVersion.cs create mode 100644 backend/LexCore/Entities/FwLiteRelease.cs diff --git a/backend/FwLite/FwLiteDesktop/App.xaml.cs b/backend/FwLite/FwLiteDesktop/App.xaml.cs index 7c66fda27..3fd91635f 100644 --- a/backend/FwLite/FwLiteDesktop/App.xaml.cs +++ b/backend/FwLite/FwLiteDesktop/App.xaml.cs @@ -2,10 +2,19 @@ public partial class App : Application { + private readonly MainPage _mainPage; + public App(MainPage mainPage) { + _mainPage = mainPage; InitializeComponent(); + } - MainPage = mainPage; + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(_mainPage) + { + Title = "FieldWorks lite " + AppVersion.Version + }; } } diff --git a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs b/backend/FwLite/FwLiteDesktop/AppUpdateService.cs new file mode 100644 index 000000000..e7dfbc86a --- /dev/null +++ b/backend/FwLite/FwLiteDesktop/AppUpdateService.cs @@ -0,0 +1,82 @@ +using System.Buffers; +using System.Net.Http.Json; +using Windows.Management.Deployment; +using LexCore.Entities; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace FwLiteDesktop; + +public class AppUpdateService( + IHttpClientFactory httpClientFactory, + ILogger logger, + IPreferences preferences) : IMauiInitializeService +{ + + + private const string LastUpdateCheck = "lastUpdateChecked"; + private const string FwliteUpdateUrlEnvVar = "FWLITE_UPDATE_URL"; + private const string ForceUpdateCheckEnvVar = "FWLITE_FORCE_UPDATE_CHECK"; + private static readonly SearchValues ValidPositiveEnvVarValues = SearchValues.Create([ "1", "true", "yes" ], StringComparison.OrdinalIgnoreCase); + private static readonly string UpdateUrl = Environment.GetEnvironmentVariable(FwliteUpdateUrlEnvVar) ?? "https://lexbox.org/api/fwlite-release/latest"; + + public void Initialize(IServiceProvider services) + { + _ = Task.Run(TryUpdate); + } + + private async Task TryUpdate() + { + if (!ShouldCheckForUpdate()) return; + var latestRelease = await FetchRelease(); + if (latestRelease is null) return; + var currentVersion = AppVersion.Version; + var shouldUpdateToRelease = String.Compare(latestRelease.Version, currentVersion, StringComparison.Ordinal) > 0; + if (!shouldUpdateToRelease) + { + logger.LogInformation("Version {CurrentVersion} is more recent than latest release {LatestRelease}, not updating", currentVersion, latestRelease.Version); + return; + } + + logger.LogInformation("New version available: {Version}", latestRelease.Version); + var packageManager = new PackageManager(); + var asyncOperation = packageManager.AddPackageAsync(new Uri(latestRelease.Url), [], DeploymentOptions.None); + asyncOperation.Progress = (info, progressInfo) => + { + logger.LogInformation("Downloading update: {ProgressPercentage}%", progressInfo.percentage); + }; + var result = await asyncOperation.AsTask(); + if (!string.IsNullOrEmpty(result.ErrorText)) + { + logger.LogError(result.ExtendedErrorCode, "Failed to download update: {ErrorText}", result.ErrorText); + return; + } + logger.LogInformation("Update downloaded, will install on next restart"); + } + + private async Task FetchRelease() + { + try + { + var latestRelease = await httpClientFactory + .CreateClient("Lexbox") + .GetFromJsonAsync(UpdateUrl); + return latestRelease; + } + catch (Exception e) + { + logger.LogError(e, "Failed to fetch latest release"); + return null; + } + } + + private bool ShouldCheckForUpdate() + { + if (ValidPositiveEnvVarValues.Contains(Environment.GetEnvironmentVariable(ForceUpdateCheckEnvVar) ?? "")) + return true; + var lastChecked = preferences.Get(LastUpdateCheck, DateTime.MinValue); + if (lastChecked.AddDays(1) > DateTime.UtcNow) return false; + preferences.Set(LastUpdateCheck, DateTime.UtcNow); + return true; + } +} diff --git a/backend/FwLite/FwLiteDesktop/AppVersion.cs b/backend/FwLite/FwLiteDesktop/AppVersion.cs new file mode 100644 index 000000000..16f9f3421 --- /dev/null +++ b/backend/FwLite/FwLiteDesktop/AppVersion.cs @@ -0,0 +1,9 @@ +using System.Reflection; + +namespace FwLiteDesktop; + +public class AppVersion +{ + public static readonly string Version = typeof(AppVersion).Assembly + .GetCustomAttribute()?.InformationalVersion ?? "dev"; +} diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs index ece707252..2d60edfc2 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs @@ -22,6 +22,7 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services, #if DEBUG environment = "Development"; #endif + var defaultDataPath = IsPackagedApp ? FileSystem.AppDataDirectory : Directory.GetCurrentDirectory(); var baseDataPath = Path.GetFullPath(configuration.GetSection("FwLiteDesktop").GetValue("BaseDataDir") ?? defaultDataPath); Directory.CreateDirectory(baseDataPath); @@ -41,7 +42,11 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services, //using a lambda here means that the serverManager will be disposed when the app is disposed services.AddSingleton(_ => serverManager); services.AddSingleton(_ => _.GetRequiredService()); + services.AddHttpClient(); + if (IsPackagedApp) + services.AddSingleton(); services.AddSingleton(_ => _.GetRequiredService().WebServices.GetRequiredService()); + services.AddSingleton(Preferences.Default); configuration.Add(source => source.ServerManager = serverManager); services.AddOptions().BindConfiguration("LocalWebApp"); logging.AddFile(Path.Combine(baseDataPath, "app.log")); diff --git a/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json b/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json index 1f221c031..71d38a589 100644 --- a/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json +++ b/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json @@ -3,7 +3,8 @@ "Run": { "commandName": "Project", "environmentVariables": { - "COREHOST_TRACE": "0" + "COREHOST_TRACE": "0", + "FWLITE_UPDATE_URL": "http://localhost:3000/api/fwlite-release/latest" } }, "Windows Machine": { diff --git a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs index b78af9423..010233fa3 100644 --- a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs +++ b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using LexCore.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Hybrid; @@ -8,23 +9,41 @@ namespace LexBoxApi.Controllers; [ApiController] [Route("/api/fwlite-release")] +[ApiExplorerSettings(GroupName = LexBoxKernel.OpenApiPublicDocumentName)] public class FwLiteReleaseController(IHttpClientFactory factory, HybridCache cache) : ControllerBase { private const string GithubLatestRelease = "GithubLatestRelease"; + [HttpGet("download-latest")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task LatestDownload() + { + var latestRelease = await GetLatestRelease(default); + if (latestRelease is null) return NotFound(); + return Redirect(latestRelease.Url); + } + [HttpGet("latest")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Latest() + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesDefaultResponseType] + public async ValueTask> LatestRelease() + { + var latestRelease = await GetLatestRelease(default); + if (latestRelease is null) return NotFound(); + return latestRelease; + } + + private async ValueTask GetLatestRelease(CancellationToken token) { - var latestReleaseUrl = await cache.GetOrCreateAsync(GithubLatestRelease, - LatestVersionFromGithub, - new HybridCacheEntryOptions() { Expiration = TimeSpan.FromDays(1) }); - if (latestReleaseUrl is null) return NotFound(); - return Redirect(latestReleaseUrl); + return await cache.GetOrCreateAsync(GithubLatestRelease, + FetchLatestReleaseFromGithub, + new HybridCacheEntryOptions() { Expiration = TimeSpan.FromDays(1) }, cancellationToken: token); } - private async ValueTask LatestVersionFromGithub(CancellationToken token) + private async ValueTask FetchLatestReleaseFromGithub(CancellationToken token) { var response = await factory.CreateClient("Github") .SendAsync(new HttpRequestMessage(HttpMethod.Get, @@ -55,7 +74,7 @@ public async Task Latest() var msixBundle = release.Assets.FirstOrDefault(a => a.Name.EndsWith(".msixbundle", StringComparison.InvariantCultureIgnoreCase)); if (msixBundle is not null) { - return msixBundle.BrowserDownloadUrl; + return new FwLiteRelease(release.TagName, msixBundle.BrowserDownloadUrl); } } return null; @@ -137,7 +156,7 @@ public class Release /// The name of the tag. /// [JsonPropertyName("tag_name")] - public string? TagName { get; set; } + public required string TagName { get; set; } [JsonPropertyName("tarball_url")] public string? TarballUrl { get; set; } diff --git a/backend/LexCore/Entities/FwLiteRelease.cs b/backend/LexCore/Entities/FwLiteRelease.cs new file mode 100644 index 000000000..d4f2afd56 --- /dev/null +++ b/backend/LexCore/Entities/FwLiteRelease.cs @@ -0,0 +1,3 @@ +namespace LexCore.Entities; + +public record FwLiteRelease(string Version, string Url); From b36f9df161d5e42d118d5f107fd5f3a1149882a7 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 2 Dec 2024 13:42:05 +0700 Subject: [PATCH 04/19] refactor TryUpdate and pull out apply update code --- backend/FwLite/FwLiteDesktop/AppUpdateService.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs b/backend/FwLite/FwLiteDesktop/AppUpdateService.cs index e7dfbc86a..335f0451c 100644 --- a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs +++ b/backend/FwLite/FwLiteDesktop/AppUpdateService.cs @@ -38,6 +38,11 @@ private async Task TryUpdate() return; } + await ApplyUpdate(latestRelease); + } + + private async Task ApplyUpdate(FwLiteRelease latestRelease) + { logger.LogInformation("New version available: {Version}", latestRelease.Version); var packageManager = new PackageManager(); var asyncOperation = packageManager.AddPackageAsync(new Uri(latestRelease.Url), [], DeploymentOptions.None); From 56da30b06653f25539de3897cdc721a4b7b93d86 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 2 Dec 2024 13:58:08 +0700 Subject: [PATCH 05/19] pass app version and a user agent identifying fw lite --- .../FwLite/FwLiteDesktop/AppUpdateService.cs | 40 +++++++++++++++---- .../Controllers/FwLiteReleaseController.cs | 5 ++- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs b/backend/FwLite/FwLiteDesktop/AppUpdateService.cs index 335f0451c..41a5706a3 100644 --- a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs +++ b/backend/FwLite/FwLiteDesktop/AppUpdateService.cs @@ -12,13 +12,16 @@ public class AppUpdateService( ILogger logger, IPreferences preferences) : IMauiInitializeService { - - private const string LastUpdateCheck = "lastUpdateChecked"; private const string FwliteUpdateUrlEnvVar = "FWLITE_UPDATE_URL"; private const string ForceUpdateCheckEnvVar = "FWLITE_FORCE_UPDATE_CHECK"; - private static readonly SearchValues ValidPositiveEnvVarValues = SearchValues.Create([ "1", "true", "yes" ], StringComparison.OrdinalIgnoreCase); - private static readonly string UpdateUrl = Environment.GetEnvironmentVariable(FwliteUpdateUrlEnvVar) ?? "https://lexbox.org/api/fwlite-release/latest"; + private const string PreventUpdateCheckEnvVar = "FWLITE_PREVENT_UPDATE"; + + private static readonly SearchValues ValidPositiveEnvVarValues = + SearchValues.Create(["1", "true", "yes"], StringComparison.OrdinalIgnoreCase); + + private static readonly string UpdateUrl = Environment.GetEnvironmentVariable(FwliteUpdateUrlEnvVar) ?? + $"https://lexbox.org/api/fwlite-release/latest?appVersion={AppVersion.Version}"; public void Initialize(IServiceProvider services) { @@ -27,6 +30,12 @@ public void Initialize(IServiceProvider services) private async Task TryUpdate() { + if (ValidPositiveEnvVarValues.Contains(Environment.GetEnvironmentVariable(PreventUpdateCheckEnvVar) ?? "")) + { + logger.LogInformation("Update check prevented by env var {EnvVar}", PreventUpdateCheckEnvVar); + return; + } + if (!ShouldCheckForUpdate()) return; var latestRelease = await FetchRelease(); if (latestRelease is null) return; @@ -34,7 +43,10 @@ private async Task TryUpdate() var shouldUpdateToRelease = String.Compare(latestRelease.Version, currentVersion, StringComparison.Ordinal) > 0; if (!shouldUpdateToRelease) { - logger.LogInformation("Version {CurrentVersion} is more recent than latest release {LatestRelease}, not updating", currentVersion, latestRelease.Version); + logger.LogInformation( + "Version {CurrentVersion} is more recent than latest release {LatestRelease}, not updating", + currentVersion, + latestRelease.Version); return; } @@ -56,6 +68,7 @@ private async Task ApplyUpdate(FwLiteRelease latestRelease) logger.LogError(result.ExtendedErrorCode, "Failed to download update: {ErrorText}", result.ErrorText); return; } + logger.LogInformation("Update downloaded, will install on next restart"); } @@ -63,9 +76,22 @@ private async Task ApplyUpdate(FwLiteRelease latestRelease) { try { - var latestRelease = await httpClientFactory + var response = await httpClientFactory .CreateClient("Lexbox") - .GetFromJsonAsync(UpdateUrl); + .SendAsync(new HttpRequestMessage(HttpMethod.Get, UpdateUrl) + { + Headers = { { "User-Agent", $"Fieldworks-Lite-Client/{AppVersion.Version}" } } + }); + if (!response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + logger.LogError("Failed to get latest release from github: {StatusCode} {ResponseContent}", + response.StatusCode, + responseContent); + return null; + } + + var latestRelease = await response.Content.ReadFromJsonAsync(); return latestRelease; } catch (Exception e) diff --git a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs index 010233fa3..d470f8fd3 100644 --- a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs +++ b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using LexBoxApi.Otel; using LexCore.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -29,8 +30,10 @@ public async Task LatestDownload() [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesDefaultResponseType] - public async ValueTask> LatestRelease() + public async ValueTask> LatestRelease(string? appVersion = null) { + using var activity = LexBoxActivitySource.Get().StartActivity(); + activity?.AddTag("app.fw-lite-client.version", appVersion ?? "unknown"); var latestRelease = await GetLatestRelease(default); if (latestRelease is null) return NotFound(); return latestRelease; From c8a2f56d11f5d23c92d4570cb8d879893d9cf5a5 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 2 Dec 2024 14:27:40 +0700 Subject: [PATCH 06/19] validate the time since last update check and ignore it if it's in the future --- backend/FwLite/FwLiteDesktop/AppUpdateService.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs b/backend/FwLite/FwLiteDesktop/AppUpdateService.cs index 41a5706a3..de777ccd7 100644 --- a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs +++ b/backend/FwLite/FwLiteDesktop/AppUpdateService.cs @@ -106,7 +106,14 @@ private bool ShouldCheckForUpdate() if (ValidPositiveEnvVarValues.Contains(Environment.GetEnvironmentVariable(ForceUpdateCheckEnvVar) ?? "")) return true; var lastChecked = preferences.Get(LastUpdateCheck, DateTime.MinValue); - if (lastChecked.AddDays(1) > DateTime.UtcNow) return false; + var timeSinceLastCheck = DateTime.UtcNow - lastChecked; + //if last checked is in the future (should never happen), then we want to reset the time and check again + if (timeSinceLastCheck.Hours < -1) + { + preferences.Set(LastUpdateCheck, DateTime.UtcNow); + return true; + } + if (timeSinceLastCheck.Hours < 20) return false; preferences.Set(LastUpdateCheck, DateTime.UtcNow); return true; } From ae6ee8ce7bca41ee6a237df175084b331c0aec66 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 3 Dec 2024 12:19:32 +0700 Subject: [PATCH 07/19] add comment about launch profiles --- backend/FwLite/FwLiteDesktop/Properties/launchSettings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json b/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json index 71d38a589..2f9865087 100644 --- a/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json +++ b/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json @@ -7,6 +7,7 @@ "FWLITE_UPDATE_URL": "http://localhost:3000/api/fwlite-release/latest" } }, + //won't work when WindowsPackageType is None, not sure how to make this work with both profiles without changing this "Windows Machine": { "commandName": "MsixPackage", "nativeDebugging": false From 88afbf50bed2afcf9a2cded6469215d68b94eafb Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 3 Dec 2024 12:20:11 +0700 Subject: [PATCH 08/19] ensure InformationalVersion is set --- backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj index 14017906e..d37d8b546 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj @@ -33,6 +33,8 @@ 1.0 1 + + $(ApplicationDisplayVersion) 11.0 13.1 From fa2e4a982b87ecc817a9b49963cd17b05dc3a04d Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 3 Dec 2024 12:21:00 +0700 Subject: [PATCH 09/19] create a desktop shortcut on first launch --- .../FwLiteDesktop/FwLiteDesktopKernel.cs | 11 +++- backend/FwLite/FwLiteDesktop/MauiProgram.cs | 4 ++ .../FwLiteDesktop/WindowsShortcutService.cs | 61 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 backend/FwLite/FwLiteDesktop/WindowsShortcutService.cs diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs index 2d60edfc2..b3bcda673 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs @@ -1,4 +1,5 @@ -using Windows.ApplicationModel; +using System.Runtime.InteropServices; +using Windows.ApplicationModel; using FwLiteDesktop.ServerBridge; using FwLiteShared.Auth; using LcmCrdt; @@ -44,9 +45,17 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services, services.AddSingleton(_ => _.GetRequiredService()); services.AddHttpClient(); if (IsPackagedApp) + { services.AddSingleton(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + services.AddSingleton(); + } + } + services.AddSingleton(_ => _.GetRequiredService().WebServices.GetRequiredService()); services.AddSingleton(Preferences.Default); + services.AddSingleton(VersionTracking.Default); configuration.Add(source => source.ServerManager = serverManager); services.AddOptions().BindConfiguration("LocalWebApp"); logging.AddFile(Path.Combine(baseDataPath, "app.log")); diff --git a/backend/FwLite/FwLiteDesktop/MauiProgram.cs b/backend/FwLite/FwLiteDesktop/MauiProgram.cs index ef91d179f..c04d4ede1 100644 --- a/backend/FwLite/FwLiteDesktop/MauiProgram.cs +++ b/backend/FwLite/FwLiteDesktop/MauiProgram.cs @@ -26,6 +26,10 @@ public static MauiApp CreateMauiApp() fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); }); + builder.ConfigureEssentials(essentialsBuilder => + { + essentialsBuilder.UseVersionTracking(); + }); builder.ConfigureLifecycleEvents(events => events.AddWindows(windowsEvents => { windowsEvents.OnClosed((window, args) => diff --git a/backend/FwLite/FwLiteDesktop/WindowsShortcutService.cs b/backend/FwLite/FwLiteDesktop/WindowsShortcutService.cs new file mode 100644 index 000000000..b2c855186 --- /dev/null +++ b/backend/FwLite/FwLiteDesktop/WindowsShortcutService.cs @@ -0,0 +1,61 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; +using Windows.ApplicationModel; + +namespace FwLiteDesktop; + +public class WindowsShortcutService(IVersionTracking versionTracking) : IMauiInitializeService +{ + public void Initialize(IServiceProvider services) + { + if (!FwLiteDesktopKernel.IsPackagedApp || !versionTracking.IsFirstLaunchEver || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + var package = Package.Current; + IShellLink link = (IShellLink)new ShellLink(); + link.SetPath($@"shell:AppsFolder\{package.Id.FamilyName}!App"); + link.SetIconLocation(Path.Combine(package.InstalledLocation.Path, "logo_light.ico"), 0); + var file = (IPersistFile)link; + file.Save(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "Fieldworks Lite.lnk"), + false); + } + + + [ComImport] + [Guid("00021401-0000-0000-C000-000000000046")] + internal class ShellLink + { + } + + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214F9-0000-0000-C000-000000000046")] + internal interface IShellLink + { + void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, + int cchMaxPath, + out IntPtr pfd, + int fFlags); + + void GetIDList(out IntPtr ppidl); + void SetIDList(IntPtr pidl); + void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName); + void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath); + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath); + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + void GetHotkey(out short pwHotkey); + void SetHotkey(short wHotkey); + void GetShowCmd(out int piShowCmd); + void SetShowCmd(int iShowCmd); + + void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, + int cchIconPath, + out int piIcon); + + void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved); + void Resolve(IntPtr hwnd, int fFlags); + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); + } +} From 64fb9032af7654e0de6ef876a8a643ddf106e3fd Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 3 Dec 2024 13:39:52 +0700 Subject: [PATCH 10/19] correct release invalidation url --- .github/workflows/fw-lite.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 97a015d39..17a3b2b92 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -262,5 +262,5 @@ jobs: continue-on-error: true run: | sleep 10 - curl -X POST https://lexbox.org/api/api/fwlite-release/new-release + curl -X POST https://lexbox.org/api/fwlite-release/new-release From 43355b1f110d21fef0565826ebfa44b76abe4b60 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 3 Dec 2024 14:00:10 +0700 Subject: [PATCH 11/19] move Windows-specific code into the Windows platform folder --- .../FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs | 10 ++-------- backend/FwLite/FwLiteDesktop/MauiProgram.cs | 5 ++++- .../{ => Platforms/Windows}/AppUpdateService.cs | 14 +++++++++++--- .../Platforms/Windows/WindowsKernel.cs | 16 ++++++++++++++++ .../Windows}/WindowsShortcutService.cs | 0 5 files changed, 33 insertions(+), 12 deletions(-) rename backend/FwLite/FwLiteDesktop/{ => Platforms/Windows}/AppUpdateService.cs (92%) create mode 100644 backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsKernel.cs rename backend/FwLite/FwLiteDesktop/{ => Platforms/Windows}/WindowsShortcutService.cs (100%) diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs index b3bcda673..abc38ef55 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs @@ -44,18 +44,12 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services, services.AddSingleton(_ => serverManager); services.AddSingleton(_ => _.GetRequiredService()); services.AddHttpClient(); - if (IsPackagedApp) - { - services.AddSingleton(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - services.AddSingleton(); - } - } + services.AddSingleton(_ => _.GetRequiredService().WebServices.GetRequiredService()); services.AddSingleton(Preferences.Default); services.AddSingleton(VersionTracking.Default); + services.AddSingleton(Connectivity.Current); configuration.Add(source => source.ServerManager = serverManager); services.AddOptions().BindConfiguration("LocalWebApp"); logging.AddFile(Path.Combine(baseDataPath, "app.log")); diff --git a/backend/FwLite/FwLiteDesktop/MauiProgram.cs b/backend/FwLite/FwLiteDesktop/MauiProgram.cs index c04d4ede1..c4da50df4 100644 --- a/backend/FwLite/FwLiteDesktop/MauiProgram.cs +++ b/backend/FwLite/FwLiteDesktop/MauiProgram.cs @@ -1,4 +1,5 @@ using FwLiteDesktop.ServerBridge; +using FwLiteDesktop.WinUI; using LcmCrdt; using LocalWebApp; using Microsoft.Extensions.Configuration; @@ -37,7 +38,9 @@ public static MauiApp CreateMauiApp() holder.App?.Services.GetRequiredService().Stop(); }); })); - + #if WINDOWS + builder.AddFwLiteWindows(); + #endif builder.Services.AddFwLiteDesktopServices(builder.Configuration, builder.Logging); holder.App = builder.Build(); diff --git a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs b/backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs similarity index 92% rename from backend/FwLite/FwLiteDesktop/AppUpdateService.cs rename to backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs index de777ccd7..eb80de884 100644 --- a/backend/FwLite/FwLiteDesktop/AppUpdateService.cs +++ b/backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs @@ -3,14 +3,14 @@ using Windows.Management.Deployment; using LexCore.Entities; using Microsoft.Extensions.Logging; -using Microsoft.Win32; namespace FwLiteDesktop; public class AppUpdateService( IHttpClientFactory httpClientFactory, ILogger logger, - IPreferences preferences) : IMauiInitializeService + IPreferences preferences, + IConnectivity connectivity) : IMauiInitializeService { private const string LastUpdateCheck = "lastUpdateChecked"; private const string FwliteUpdateUrlEnvVar = "FWLITE_UPDATE_URL"; @@ -96,7 +96,15 @@ private async Task ApplyUpdate(FwLiteRelease latestRelease) } catch (Exception e) { - logger.LogError(e, "Failed to fetch latest release"); + if (connectivity.NetworkAccess == NetworkAccess.Internet) + { + logger.LogError(e, "Failed to fetch latest release"); + } + else + { + logger.LogInformation(e, "Failed to fetch latest release, no internet connection"); + } + return null; } } diff --git a/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsKernel.cs b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsKernel.cs new file mode 100644 index 000000000..675e1faa4 --- /dev/null +++ b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsKernel.cs @@ -0,0 +1,16 @@ +using System.Runtime.InteropServices; + +namespace FwLiteDesktop; + +public static class WindowsKernel +{ + public static void AddFwLiteWindows(this MauiAppBuilder builder) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + if (FwLiteDesktopKernel.IsPackagedApp) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + } +} diff --git a/backend/FwLite/FwLiteDesktop/WindowsShortcutService.cs b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs similarity index 100% rename from backend/FwLite/FwLiteDesktop/WindowsShortcutService.cs rename to backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs From 02e63131787ea327aeb29f5eb302e8ecb1ad137d Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 3 Dec 2024 14:03:19 +0700 Subject: [PATCH 12/19] fix space prefixed on header value --- backend/LexBoxApi/Controllers/FwLiteReleaseController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs index d470f8fd3..cd7d98482 100644 --- a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs +++ b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs @@ -54,7 +54,7 @@ public async ValueTask> LatestRelease(string? appVer { Headers = { - { "X-GitHub-Api-Version", " 2022-11-28" }, + { "X-GitHub-Api-Version", "2022-11-28" }, { "Accept", "application/vnd.github+json" }, { "User-Agent", "Lexbox-Release-Endpoint" } } From e01f0c07eddf9e640226e0319a1f68512ef3d78f Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 3 Dec 2024 16:26:58 +0700 Subject: [PATCH 13/19] refactor update checking code to delegate the update decision to the server, if the server returns a release, then the client will update to that version --- .../Platforms/Windows/AppUpdateService.cs | 33 +++++++------------ .../Controllers/FwLiteReleaseController.cs | 23 ++++++++++--- backend/LexCore/Entities/FwLiteRelease.cs | 10 +++++- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs b/backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs index eb80de884..ca8b0ba2b 100644 --- a/backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs +++ b/backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs @@ -20,8 +20,8 @@ public class AppUpdateService( private static readonly SearchValues ValidPositiveEnvVarValues = SearchValues.Create(["1", "true", "yes"], StringComparison.OrdinalIgnoreCase); - private static readonly string UpdateUrl = Environment.GetEnvironmentVariable(FwliteUpdateUrlEnvVar) ?? - $"https://lexbox.org/api/fwlite-release/latest?appVersion={AppVersion.Version}"; + private static readonly string ShouldUpdateUrl = Environment.GetEnvironmentVariable(FwliteUpdateUrlEnvVar) ?? + $"https://lexbox.org/api/fwlite-release/should-update?appVersion={AppVersion.Version}"; public void Initialize(IServiceProvider services) { @@ -37,20 +37,10 @@ private async Task TryUpdate() } if (!ShouldCheckForUpdate()) return; - var latestRelease = await FetchRelease(); - if (latestRelease is null) return; - var currentVersion = AppVersion.Version; - var shouldUpdateToRelease = String.Compare(latestRelease.Version, currentVersion, StringComparison.Ordinal) > 0; - if (!shouldUpdateToRelease) - { - logger.LogInformation( - "Version {CurrentVersion} is more recent than latest release {LatestRelease}, not updating", - currentVersion, - latestRelease.Version); - return; - } + var response = await ShouldUpdate(); + if (!response.Update) return; - await ApplyUpdate(latestRelease); + await ApplyUpdate(response.Release); } private async Task ApplyUpdate(FwLiteRelease latestRelease) @@ -72,27 +62,26 @@ private async Task ApplyUpdate(FwLiteRelease latestRelease) logger.LogInformation("Update downloaded, will install on next restart"); } - private async Task FetchRelease() + private async Task ShouldUpdate() { try { var response = await httpClientFactory .CreateClient("Lexbox") - .SendAsync(new HttpRequestMessage(HttpMethod.Get, UpdateUrl) + .SendAsync(new HttpRequestMessage(HttpMethod.Get, ShouldUpdateUrl) { Headers = { { "User-Agent", $"Fieldworks-Lite-Client/{AppVersion.Version}" } } }); if (!response.IsSuccessStatusCode) { var responseContent = await response.Content.ReadAsStringAsync(); - logger.LogError("Failed to get latest release from github: {StatusCode} {ResponseContent}", + logger.LogError("Failed to get should update response lexbox: {StatusCode} {ResponseContent}", response.StatusCode, responseContent); - return null; + return new ShouldUpdateResponse(null); } - var latestRelease = await response.Content.ReadFromJsonAsync(); - return latestRelease; + return await response.Content.ReadFromJsonAsync() ?? new ShouldUpdateResponse(null); } catch (Exception e) { @@ -105,7 +94,7 @@ private async Task ApplyUpdate(FwLiteRelease latestRelease) logger.LogInformation(e, "Failed to fetch latest release, no internet connection"); } - return null; + return new ShouldUpdateResponse(null); } } diff --git a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs index cd7d98482..b5ed7237f 100644 --- a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs +++ b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs @@ -39,11 +39,24 @@ public async ValueTask> LatestRelease(string? appVer return latestRelease; } + [HttpGet("should-update")] + public async Task> ShouldUpdate([FromQuery] string appVersion) + { + using var activity = LexBoxActivitySource.Get().StartActivity(); + activity?.AddTag("app.fw-lite-client.version", appVersion); + var latestRelease = await GetLatestRelease(default); + if (latestRelease is null) return new ShouldUpdateResponse(null); + + var shouldUpdateToRelease = String.Compare(latestRelease.Version, appVersion, StringComparison.Ordinal) > 0; + return shouldUpdateToRelease ? new ShouldUpdateResponse(latestRelease) : new ShouldUpdateResponse(null); + } + private async ValueTask GetLatestRelease(CancellationToken token) { return await cache.GetOrCreateAsync(GithubLatestRelease, FetchLatestReleaseFromGithub, - new HybridCacheEntryOptions() { Expiration = TimeSpan.FromDays(1) }, cancellationToken: token); + new HybridCacheEntryOptions() { Expiration = TimeSpan.FromDays(1) }, + cancellationToken: token); } private async ValueTask FetchLatestReleaseFromGithub(CancellationToken token) @@ -65,6 +78,7 @@ public async ValueTask> LatestRelease(string? appVer var responseContent = await response.Content.ReadAsStringAsync(token); throw new Exception($"Failed to get latest release from github: {response.StatusCode} {responseContent}"); } + response.EnsureSuccessStatusCode(); var releases = await response.Content.ReadFromJsonAsync(token); if (releases is null) return null; @@ -74,12 +88,15 @@ public async ValueTask> LatestRelease(string? appVer { continue; } - var msixBundle = release.Assets.FirstOrDefault(a => a.Name.EndsWith(".msixbundle", StringComparison.InvariantCultureIgnoreCase)); + + var msixBundle = release.Assets.FirstOrDefault(a => + a.Name.EndsWith(".msixbundle", StringComparison.InvariantCultureIgnoreCase)); if (msixBundle is not null) { return new FwLiteRelease(release.TagName, msixBundle.BrowserDownloadUrl); } } + return null; } @@ -235,7 +252,6 @@ public class ReleaseAsset [JsonConverter(typeof(StateConverter))] public enum State { Open, Uploaded }; - internal class StateConverter : JsonConverter { public override bool CanConvert(Type t) => t == typeof(State); @@ -268,5 +284,4 @@ public override void Write(Utf8JsonWriter writer, State value, JsonSerializerOpt throw new Exception("Cannot marshal type State"); } - } diff --git a/backend/LexCore/Entities/FwLiteRelease.cs b/backend/LexCore/Entities/FwLiteRelease.cs index d4f2afd56..895deb5e5 100644 --- a/backend/LexCore/Entities/FwLiteRelease.cs +++ b/backend/LexCore/Entities/FwLiteRelease.cs @@ -1,3 +1,11 @@ -namespace LexCore.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace LexCore.Entities; public record FwLiteRelease(string Version, string Url); + +public record ShouldUpdateResponse(FwLiteRelease? Release) +{ + [MemberNotNullWhen(true, nameof(Release))] + public bool Update => Release is not null; +} From 8bba522586b3e470b0b0ad339a2cb4c54b335c0e Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 4 Dec 2024 11:04:40 +0700 Subject: [PATCH 14/19] break out update logic into its own service to call from the release controller, markup with otel activities --- .../Controllers/FwLiteReleaseController.cs | 262 ++---------------- backend/LexBoxApi/LexBoxKernel.cs | 2 + .../FwLiteReleases/FwLiteReleaseService.cs | 86 ++++++ .../Services/FwLiteReleases/GithubRelease.cs | 181 ++++++++++++ 4 files changed, 290 insertions(+), 241 deletions(-) create mode 100644 backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs create mode 100644 backend/LexBoxApi/Services/FwLiteReleases/GithubRelease.cs diff --git a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs index b5ed7237f..586fc2798 100644 --- a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs +++ b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs @@ -1,6 +1,8 @@ -using System.Text.Json; +using System.Diagnostics; +using System.Text.Json; using System.Text.Json.Serialization; using LexBoxApi.Otel; +using LexBoxApi.Services.FwLiteReleases; using LexCore.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,17 +13,22 @@ namespace LexBoxApi.Controllers; [ApiController] [Route("/api/fwlite-release")] [ApiExplorerSettings(GroupName = LexBoxKernel.OpenApiPublicDocumentName)] -public class FwLiteReleaseController(IHttpClientFactory factory, HybridCache cache) : ControllerBase +public class FwLiteReleaseController(FwLiteReleaseService releaseService) : ControllerBase { - private const string GithubLatestRelease = "GithubLatestRelease"; [HttpGet("download-latest")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task LatestDownload() + public async Task DownloadLatest() { - var latestRelease = await GetLatestRelease(default); - if (latestRelease is null) return NotFound(); + using var activity = LexBoxActivitySource.Get().StartActivity(); + var latestRelease = await releaseService.GetLatestRelease(default); + if (latestRelease is null) + { + activity?.SetStatus(ActivityStatusCode.Error, "Latest release not found"); + return NotFound(); + } + activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, latestRelease.Version); return Redirect(latestRelease.Url); } @@ -33,8 +40,9 @@ public async Task LatestDownload() public async ValueTask> LatestRelease(string? appVersion = null) { using var activity = LexBoxActivitySource.Get().StartActivity(); - activity?.AddTag("app.fw-lite-client.version", appVersion ?? "unknown"); - var latestRelease = await GetLatestRelease(default); + activity?.AddTag(FwLiteReleaseService.FwLiteClientVersionTag, appVersion ?? "unknown"); + var latestRelease = await releaseService.GetLatestRelease(default); + activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, latestRelease?.Version); if (latestRelease is null) return NotFound(); return latestRelease; } @@ -43,245 +51,17 @@ public async ValueTask> LatestRelease(string? appVer public async Task> ShouldUpdate([FromQuery] string appVersion) { using var activity = LexBoxActivitySource.Get().StartActivity(); - activity?.AddTag("app.fw-lite-client.version", appVersion); - var latestRelease = await GetLatestRelease(default); - if (latestRelease is null) return new ShouldUpdateResponse(null); - - var shouldUpdateToRelease = String.Compare(latestRelease.Version, appVersion, StringComparison.Ordinal) > 0; - return shouldUpdateToRelease ? new ShouldUpdateResponse(latestRelease) : new ShouldUpdateResponse(null); - } - - private async ValueTask GetLatestRelease(CancellationToken token) - { - return await cache.GetOrCreateAsync(GithubLatestRelease, - FetchLatestReleaseFromGithub, - new HybridCacheEntryOptions() { Expiration = TimeSpan.FromDays(1) }, - cancellationToken: token); - } - - private async ValueTask FetchLatestReleaseFromGithub(CancellationToken token) - { - var response = await factory.CreateClient("Github") - .SendAsync(new HttpRequestMessage(HttpMethod.Get, - "https://api.github.com/repos/sillsdev/languageforge-lexbox/releases") - { - Headers = - { - { "X-GitHub-Api-Version", "2022-11-28" }, - { "Accept", "application/vnd.github+json" }, - { "User-Agent", "Lexbox-Release-Endpoint" } - } - }, - token); - if (!response.IsSuccessStatusCode) - { - var responseContent = await response.Content.ReadAsStringAsync(token); - throw new Exception($"Failed to get latest release from github: {response.StatusCode} {responseContent}"); - } - - response.EnsureSuccessStatusCode(); - var releases = await response.Content.ReadFromJsonAsync(token); - if (releases is null) return null; - foreach (var release in releases) - { - if (release is { Draft: true } or { Prerelease: true }) - { - continue; - } - - var msixBundle = release.Assets.FirstOrDefault(a => - a.Name.EndsWith(".msixbundle", StringComparison.InvariantCultureIgnoreCase)); - if (msixBundle is not null) - { - return new FwLiteRelease(release.TagName, msixBundle.BrowserDownloadUrl); - } - } - - return null; + activity?.AddTag(FwLiteReleaseService.FwLiteClientVersionTag, appVersion); + var response = await releaseService.ShouldUpdate(appVersion); + activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, response.Release?.Version); + return response; } [HttpPost("new-release")] [AllowAnonymous] public async Task NewRelease() { - await cache.RemoveAsync(GithubLatestRelease); + await releaseService.InvalidateReleaseCache(); return Ok(); } } - -/// -/// A release. -/// -public class Release -{ - [JsonPropertyName("assets")] - public required ReleaseAsset[] Assets { get; set; } - - [JsonPropertyName("assets_url")] - public string? AssetsUrl { get; set; } - - [JsonPropertyName("body")] - public string? Body { get; set; } - - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("body_html")] - public string? BodyHtml { get; set; } - - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("body_text")] - public string? BodyText { get; set; } - - [JsonPropertyName("created_at")] - public DateTimeOffset CreatedAt { get; set; } - - /// - /// The URL of the release discussion. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("discussion_url")] - public string? DiscussionUrl { get; set; } - - /// - /// true to create a draft (unpublished) release, false to create a published one. - /// - [JsonPropertyName("draft")] - public bool Draft { get; set; } - - [JsonPropertyName("html_url")] - public string? HtmlUrl { get; set; } - - [JsonPropertyName("id")] - public long Id { get; set; } - - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("mentions_count")] - public long? MentionsCount { get; set; } - - [JsonPropertyName("name")] - public string? Name { get; set; } - - [JsonPropertyName("node_id")] - public string? NodeId { get; set; } - - /// - /// Whether to identify the release as a prerelease or a full release. - /// - [JsonPropertyName("prerelease")] - public bool Prerelease { get; set; } - - [JsonPropertyName("published_at")] - public DateTimeOffset? PublishedAt { get; set; } - - /// - /// The name of the tag. - /// - [JsonPropertyName("tag_name")] - public required string TagName { get; set; } - - [JsonPropertyName("tarball_url")] - public string? TarballUrl { get; set; } - - /// - /// Specifies the commitish value that determines where the Git tag is created from. - /// - [JsonPropertyName("target_commitish")] - public string? TargetCommitish { get; set; } - - [JsonPropertyName("upload_url")] - public string? UploadUrl { get; set; } - - [JsonPropertyName("url")] - public string? Url { get; set; } - - [JsonPropertyName("zipball_url")] - public string? ZipballUrl { get; set; } -} - -/// -/// Data related to a release. -/// -public class ReleaseAsset -{ - [JsonPropertyName("browser_download_url")] - public required string BrowserDownloadUrl { get; set; } - - [JsonPropertyName("content_type")] - public string? ContentType { get; set; } - - [JsonPropertyName("created_at")] - public DateTimeOffset CreatedAt { get; set; } - - [JsonPropertyName("download_count")] - public long DownloadCount { get; set; } - - [JsonPropertyName("id")] - public long Id { get; set; } - - [JsonPropertyName("label")] - public string? Label { get; set; } - - /// - /// The file name of the asset. - /// - [JsonPropertyName("name")] - public required string Name { get; set; } - - [JsonPropertyName("node_id")] - public string? NodeId { get; set; } - - [JsonPropertyName("size")] - public long Size { get; set; } - - /// - /// State of the release asset. - /// - [JsonPropertyName("state")] - public State State { get; set; } - - [JsonPropertyName("updated_at")] - public DateTimeOffset UpdatedAt { get; set; } - - - [JsonPropertyName("url")] - public string? Url { get; set; } -} - -/// -/// State of the release asset. -/// -[JsonConverter(typeof(StateConverter))] -public enum State { Open, Uploaded }; - -internal class StateConverter : JsonConverter -{ - public override bool CanConvert(Type t) => t == typeof(State); - - public override State Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - switch (value) - { - case "open": - return State.Open; - case "uploaded": - return State.Uploaded; - } - - throw new Exception("Cannot unmarshal type State"); - } - - public override void Write(Utf8JsonWriter writer, State value, JsonSerializerOptions options) - { - switch (value) - { - case State.Open: - JsonSerializer.Serialize(writer, "open", options); - return; - case State.Uploaded: - JsonSerializer.Serialize(writer, "uploaded", options); - return; - } - - throw new Exception("Cannot marshal type State"); - } -} diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index a92217cf2..ae464fc3f 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -4,6 +4,7 @@ using LexBoxApi.GraphQL.CustomTypes; using LexBoxApi.Services; using LexBoxApi.Services.Email; +using LexBoxApi.Services.FwLiteReleases; using LexCore.Config; using LexCore.ServiceInterfaces; using LexSyncReverseProxy; @@ -62,6 +63,7 @@ public static void AddLexBoxApi(this IServiceCollection services, services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddHostedService(); services.AddTransient(); services.AddTransient(); diff --git a/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs b/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs new file mode 100644 index 000000000..59a3488ad --- /dev/null +++ b/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using LexBoxApi.Controllers; +using LexBoxApi.Otel; +using LexCore.Entities; +using Microsoft.Extensions.Caching.Hybrid; + +namespace LexBoxApi.Services.FwLiteReleases; + +public class FwLiteReleaseService(IHttpClientFactory factory, HybridCache cache) +{ + private const string GithubLatestRelease = "GithubLatestRelease"; + public const string FwLiteClientVersionTag = "app.fw-lite.client.version"; + public const string FwLiteReleaseVersionTag = "app.fw-lite.release.version"; + + public async ValueTask GetLatestRelease(CancellationToken token) + { + return await cache.GetOrCreateAsync(GithubLatestRelease, + FetchLatestReleaseFromGithub, + new HybridCacheEntryOptions() { Expiration = TimeSpan.FromDays(1) }, + cancellationToken: token); + } + + public async ValueTask ShouldUpdate(string appVersion) + { + var latestRelease = await GetLatestRelease(default); + if (latestRelease is null) return new ShouldUpdateResponse(null); + + var shouldUpdateToRelease = String.Compare(latestRelease.Version, appVersion, StringComparison.Ordinal) > 0; + return shouldUpdateToRelease ? new ShouldUpdateResponse(latestRelease) : new ShouldUpdateResponse(null); + } + + public async ValueTask InvalidateReleaseCache() + { + await cache.RemoveAsync(GithubLatestRelease); + } + + private async ValueTask FetchLatestReleaseFromGithub(CancellationToken token) + { + using var activity = LexBoxActivitySource.Get().StartActivity(); + var response = await factory.CreateClient("Github") + .SendAsync(new HttpRequestMessage(HttpMethod.Get, + "https://api.github.com/repos/sillsdev/languageforge-lexbox/releases") + { + Headers = + { + { "X-GitHub-Api-Version", "2022-11-28" }, + { "Accept", "application/vnd.github+json" }, + { "User-Agent", "Lexbox-Release-Endpoint" } + } + }, + token); + if (!response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(token); + var e = new Exception($"Failed to get latest release from github: {response.StatusCode} {responseContent}"); + activity?.SetStatus(ActivityStatusCode.Error, e.Message); + activity?.AddException(e); + throw e; + } + + response.EnsureSuccessStatusCode(); + var releases = await response.Content.ReadFromJsonAsync(token); + if (releases is not null) + { + foreach (var release in releases) + { + if (release is { Draft: true } or { Prerelease: true }) + { + continue; + } + + var msixBundle = release.Assets.FirstOrDefault(a => + a.Name.EndsWith(".msixbundle", StringComparison.InvariantCultureIgnoreCase)); + if (msixBundle is not null) + { + activity?.AddTag(FwLiteReleaseVersionTag, release.TagName); + return new FwLiteRelease(release.TagName, msixBundle.BrowserDownloadUrl); + } + } + } + + activity?.SetStatus(ActivityStatusCode.Error, "No release found"); + activity?.AddTag(FwLiteReleaseVersionTag, null); + return null; + } +} diff --git a/backend/LexBoxApi/Services/FwLiteReleases/GithubRelease.cs b/backend/LexBoxApi/Services/FwLiteReleases/GithubRelease.cs new file mode 100644 index 000000000..0e0e2af4d --- /dev/null +++ b/backend/LexBoxApi/Services/FwLiteReleases/GithubRelease.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LexBoxApi.Services.FwLiteReleases; + +/// +/// A release. +/// +public class GithubRelease +{ + [JsonPropertyName("assets")] + public required ReleaseAsset[] Assets { get; set; } + + [JsonPropertyName("assets_url")] + public string? AssetsUrl { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("body_html")] + public string? BodyHtml { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("body_text")] + public string? BodyText { get; set; } + + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; set; } + + /// + /// The URL of the release discussion. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("discussion_url")] + public string? DiscussionUrl { get; set; } + + /// + /// true to create a draft (unpublished) release, false to create a published one. + /// + [JsonPropertyName("draft")] + public bool Draft { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mentions_count")] + public long? MentionsCount { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("node_id")] + public string? NodeId { get; set; } + + /// + /// Whether to identify the release as a prerelease or a full release. + /// + [JsonPropertyName("prerelease")] + public bool Prerelease { get; set; } + + [JsonPropertyName("published_at")] + public DateTimeOffset? PublishedAt { get; set; } + + /// + /// The name of the tag. + /// + [JsonPropertyName("tag_name")] + public required string TagName { get; set; } + + [JsonPropertyName("tarball_url")] + public string? TarballUrl { get; set; } + + /// + /// Specifies the commitish value that determines where the Git tag is created from. + /// + [JsonPropertyName("target_commitish")] + public string? TargetCommitish { get; set; } + + [JsonPropertyName("upload_url")] + public string? UploadUrl { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("zipball_url")] + public string? ZipballUrl { get; set; } +} + +/// +/// Data related to a release. +/// +public class ReleaseAsset +{ + [JsonPropertyName("browser_download_url")] + public required string BrowserDownloadUrl { get; set; } + + [JsonPropertyName("content_type")] + public string? ContentType { get; set; } + + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonPropertyName("download_count")] + public long DownloadCount { get; set; } + + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("label")] + public string? Label { get; set; } + + /// + /// The file name of the asset. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("node_id")] + public string? NodeId { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } + + /// + /// State of the release asset. + /// + [JsonPropertyName("state")] + public State State { get; set; } + + [JsonPropertyName("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } + + + [JsonPropertyName("url")] + public string? Url { get; set; } +} + +/// +/// State of the release asset. +/// +[JsonConverter(typeof(StateConverter))] +public enum State { Open, Uploaded }; + +internal class StateConverter : JsonConverter +{ + public override bool CanConvert(Type t) => t == typeof(State); + + public override State Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "open": + return State.Open; + case "uploaded": + return State.Uploaded; + } + + throw new Exception("Cannot unmarshal type State"); + } + + public override void Write(Utf8JsonWriter writer, State value, JsonSerializerOptions options) + { + switch (value) + { + case State.Open: + JsonSerializer.Serialize(writer, "open", options); + return; + case State.Uploaded: + JsonSerializer.Serialize(writer, "uploaded", options); + return; + } + + throw new Exception("Cannot marshal type State"); + } +} From 87efa3df4d06dab7a6d431ae18bcb1fc9f29b45c Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 4 Dec 2024 11:22:41 +0700 Subject: [PATCH 15/19] write tests for release service --- .../FwLiteReleases/FwLiteReleaseService.cs | 7 +- .../Services/FwLiteReleaseServiceTests.cs | 73 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 backend/Testing/LexCore/Services/FwLiteReleaseServiceTests.cs diff --git a/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs b/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs index 59a3488ad..ffa0645e8 100644 --- a/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs +++ b/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs @@ -25,10 +25,15 @@ public async ValueTask ShouldUpdate(string appVersion) var latestRelease = await GetLatestRelease(default); if (latestRelease is null) return new ShouldUpdateResponse(null); - var shouldUpdateToRelease = String.Compare(latestRelease.Version, appVersion, StringComparison.Ordinal) > 0; + var shouldUpdateToRelease = ShouldUpdateToRelease(appVersion, latestRelease.Version); return shouldUpdateToRelease ? new ShouldUpdateResponse(latestRelease) : new ShouldUpdateResponse(null); } + public static bool ShouldUpdateToRelease(string appVersion, string latestVersion) + { + return String.Compare(latestVersion, appVersion, StringComparison.Ordinal) > 0; + } + public async ValueTask InvalidateReleaseCache() { await cache.RemoveAsync(GithubLatestRelease); diff --git a/backend/Testing/LexCore/Services/FwLiteReleaseServiceTests.cs b/backend/Testing/LexCore/Services/FwLiteReleaseServiceTests.cs new file mode 100644 index 000000000..d3ea7e2fc --- /dev/null +++ b/backend/Testing/LexCore/Services/FwLiteReleaseServiceTests.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using LexBoxApi; +using LexBoxApi.Services.FwLiteReleases; +using Microsoft.Extensions.DependencyInjection; +using Testing.Fixtures; + +namespace Testing.LexCore.Services; + +public class FwLiteReleaseServiceTests +{ + private readonly FwLiteReleaseService _fwLiteReleaseService; + + public FwLiteReleaseServiceTests() + { + //disable warning about hybrid cache being experimental +#pragma warning disable EXTEXP0018 + var services = new ServiceCollection().AddSingleton().AddHttpClient() + .AddHybridCache() + .Services.BuildServiceProvider(); +#pragma warning restore EXTEXP0018 + _fwLiteReleaseService = services.GetRequiredService(); + } + + [Fact] + public async Task CanGetLatestRelease() + { + var latestRelease = await _fwLiteReleaseService.GetLatestRelease(default); + latestRelease.Should().NotBeNull(); + latestRelease.Version.Should().NotBeNullOrEmpty(); + latestRelease.Url.Should().NotBeNullOrEmpty(); + } + + [Theory] + [InlineData("v2024-11-20-d04e9b96")] + public async Task IsConsideredAnOldVersion(string appVersion) + { + var shouldUpdate = await _fwLiteReleaseService.ShouldUpdate(appVersion); + shouldUpdate.Should().NotBeNull(); + shouldUpdate.Release.Should().NotBeNull(); + shouldUpdate.Update.Should().BeTrue(); + } + + [Fact] + public async Task ShouldUpdateWithLatestVersionShouldReturnFalse() + { + var latestRelease = await _fwLiteReleaseService.GetLatestRelease(default); + latestRelease.Should().NotBeNull(); + var shouldUpdate = await _fwLiteReleaseService.ShouldUpdate(latestRelease.Version); + shouldUpdate.Should().NotBeNull(); + shouldUpdate.Release.Should().BeNull(); + shouldUpdate.Update.Should().BeFalse(); + } + + [Theory] + [InlineData( + "v2024-11-20-d04e9b96", + "v2024-11-20-d04e9b96", + false, + "there's no need to update when you have the latest version")] + [InlineData( + "v2024-11-20-d04e9b96", + "v2024-11-27-c54f64d1", + true, + "there's a need to update when you have an older version")] + public void ShouldUpdateToReleaseGivesExpectedResult(string appVersion, + string latestVersion, + bool expected, + string reason) + { + var actual = FwLiteReleaseService.ShouldUpdateToRelease(appVersion, latestVersion); + actual.Should().Be(expected, reason); + } +} From dd498f793b503de718223fcb8f06aae733d97147 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 4 Dec 2024 11:27:24 +0700 Subject: [PATCH 16/19] increase timeout on fw lite build and test job --- .github/workflows/fw-lite.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 17a3b2b92..5b3c2ccc9 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -23,7 +23,7 @@ on: jobs: build-and-test: name: Build FW Lite and run tests - timeout-minutes: 20 + timeout-minutes: 30 runs-on: windows-latest outputs: version: ${{ steps.setVersion.outputs.VERSION }} From 224388da3447a3e83ac3dcc74277332525c91456 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 9 Dec 2024 09:51:52 +0700 Subject: [PATCH 17/19] make app name consistent --- backend/FwLite/FwLiteDesktop/App.xaml.cs | 2 +- .../FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/FwLiteDesktop/App.xaml.cs b/backend/FwLite/FwLiteDesktop/App.xaml.cs index 3fd91635f..22d0bacbb 100644 --- a/backend/FwLite/FwLiteDesktop/App.xaml.cs +++ b/backend/FwLite/FwLiteDesktop/App.xaml.cs @@ -14,7 +14,7 @@ protected override Window CreateWindow(IActivationState? activationState) { return new Window(_mainPage) { - Title = "FieldWorks lite " + AppVersion.Version + Title = "FieldWorks Lite " + AppVersion.Version }; } } diff --git a/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs index b2c855186..a8795986e 100644 --- a/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs +++ b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs @@ -15,7 +15,7 @@ public void Initialize(IServiceProvider services) link.SetPath($@"shell:AppsFolder\{package.Id.FamilyName}!App"); link.SetIconLocation(Path.Combine(package.InstalledLocation.Path, "logo_light.ico"), 0); var file = (IPersistFile)link; - file.Save(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "Fieldworks Lite.lnk"), + file.Save(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "FieldWorks Lite.lnk"), false); } From 05fbec0cff07cf12adcff23255ddb67a5d193e1c Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 9 Dec 2024 11:28:19 +0700 Subject: [PATCH 18/19] don't reference the install path when making shortcut since it will change after an update --- .../FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs index a8795986e..e757bf66b 100644 --- a/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs +++ b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs @@ -13,7 +13,6 @@ public void Initialize(IServiceProvider services) var package = Package.Current; IShellLink link = (IShellLink)new ShellLink(); link.SetPath($@"shell:AppsFolder\{package.Id.FamilyName}!App"); - link.SetIconLocation(Path.Combine(package.InstalledLocation.Path, "logo_light.ico"), 0); var file = (IPersistFile)link; file.Save(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "FieldWorks Lite.lnk"), false); From da8ba2ccf15316904f30c35c3439a5619ac4baf3 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 9 Dec 2024 11:40:41 +0700 Subject: [PATCH 19/19] add a default value for GetLatestRelease cancellation token --- backend/LexBoxApi/Controllers/FwLiteReleaseController.cs | 4 ++-- .../LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs index 586fc2798..3f1c2ec09 100644 --- a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs +++ b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs @@ -22,7 +22,7 @@ public class FwLiteReleaseController(FwLiteReleaseService releaseService) : Cont public async Task DownloadLatest() { using var activity = LexBoxActivitySource.Get().StartActivity(); - var latestRelease = await releaseService.GetLatestRelease(default); + var latestRelease = await releaseService.GetLatestRelease(); if (latestRelease is null) { activity?.SetStatus(ActivityStatusCode.Error, "Latest release not found"); @@ -41,7 +41,7 @@ public async ValueTask> LatestRelease(string? appVer { using var activity = LexBoxActivitySource.Get().StartActivity(); activity?.AddTag(FwLiteReleaseService.FwLiteClientVersionTag, appVersion ?? "unknown"); - var latestRelease = await releaseService.GetLatestRelease(default); + var latestRelease = await releaseService.GetLatestRelease(); activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, latestRelease?.Version); if (latestRelease is null) return NotFound(); return latestRelease; diff --git a/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs b/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs index ffa0645e8..294a2618f 100644 --- a/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs +++ b/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs @@ -12,7 +12,7 @@ public class FwLiteReleaseService(IHttpClientFactory factory, HybridCache cache) public const string FwLiteClientVersionTag = "app.fw-lite.client.version"; public const string FwLiteReleaseVersionTag = "app.fw-lite.release.version"; - public async ValueTask GetLatestRelease(CancellationToken token) + public async ValueTask GetLatestRelease(CancellationToken token = default) { return await cache.GetOrCreateAsync(GithubLatestRelease, FetchLatestReleaseFromGithub, @@ -22,7 +22,7 @@ public class FwLiteReleaseService(IHttpClientFactory factory, HybridCache cache) public async ValueTask ShouldUpdate(string appVersion) { - var latestRelease = await GetLatestRelease(default); + var latestRelease = await GetLatestRelease(); if (latestRelease is null) return new ShouldUpdateResponse(null); var shouldUpdateToRelease = ShouldUpdateToRelease(appVersion, latestRelease.Version);