diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index f36d825e6..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 }} @@ -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/fwlite-release/new-release diff --git a/backend/FwLite/FwLiteDesktop/App.xaml.cs b/backend/FwLite/FwLiteDesktop/App.xaml.cs index 7c66fda27..22d0bacbb 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/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/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 diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs index ece707252..abc38ef55 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; @@ -22,6 +23,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 +43,13 @@ 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(); + + 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 ef91d179f..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; @@ -26,6 +27,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) => @@ -33,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/Platforms/Windows/AppUpdateService.cs b/backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs new file mode 100644 index 000000000..ca8b0ba2b --- /dev/null +++ b/backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs @@ -0,0 +1,117 @@ +using System.Buffers; +using System.Net.Http.Json; +using Windows.Management.Deployment; +using LexCore.Entities; +using Microsoft.Extensions.Logging; + +namespace FwLiteDesktop; + +public class AppUpdateService( + IHttpClientFactory httpClientFactory, + ILogger logger, + IPreferences preferences, + IConnectivity connectivity) : IMauiInitializeService +{ + private const string LastUpdateCheck = "lastUpdateChecked"; + private const string FwliteUpdateUrlEnvVar = "FWLITE_UPDATE_URL"; + private const string ForceUpdateCheckEnvVar = "FWLITE_FORCE_UPDATE_CHECK"; + private const string PreventUpdateCheckEnvVar = "FWLITE_PREVENT_UPDATE"; + + private static readonly SearchValues ValidPositiveEnvVarValues = + SearchValues.Create(["1", "true", "yes"], StringComparison.OrdinalIgnoreCase); + + private static readonly string ShouldUpdateUrl = Environment.GetEnvironmentVariable(FwliteUpdateUrlEnvVar) ?? + $"https://lexbox.org/api/fwlite-release/should-update?appVersion={AppVersion.Version}"; + + public void Initialize(IServiceProvider services) + { + _ = Task.Run(TryUpdate); + } + + 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 response = await ShouldUpdate(); + if (!response.Update) return; + + await ApplyUpdate(response.Release); + } + + 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); + 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 ShouldUpdate() + { + try + { + var response = await httpClientFactory + .CreateClient("Lexbox") + .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 should update response lexbox: {StatusCode} {ResponseContent}", + response.StatusCode, + responseContent); + return new ShouldUpdateResponse(null); + } + + return await response.Content.ReadFromJsonAsync() ?? new ShouldUpdateResponse(null); + } + catch (Exception e) + { + 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 new ShouldUpdateResponse(null); + } + } + + private bool ShouldCheckForUpdate() + { + if (ValidPositiveEnvVarValues.Contains(Environment.GetEnvironmentVariable(ForceUpdateCheckEnvVar) ?? "")) + return true; + var lastChecked = preferences.Get(LastUpdateCheck, DateTime.MinValue); + 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; + } +} 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/Platforms/Windows/WindowsShortcutService.cs b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs new file mode 100644 index 000000000..e757bf66b --- /dev/null +++ b/backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsShortcutService.cs @@ -0,0 +1,60 @@ +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"); + 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); + } +} diff --git a/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json b/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json index 1f221c031..2f9865087 100644 --- a/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json +++ b/backend/FwLite/FwLiteDesktop/Properties/launchSettings.json @@ -3,9 +3,11 @@ "Run": { "commandName": "Project", "environmentVariables": { - "COREHOST_TRACE": "0" + "COREHOST_TRACE": "0", + "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 diff --git a/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs new file mode 100644 index 000000000..3f1c2ec09 --- /dev/null +++ b/backend/LexBoxApi/Controllers/FwLiteReleaseController.cs @@ -0,0 +1,67 @@ +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; +using Microsoft.Extensions.Caching.Hybrid; + +namespace LexBoxApi.Controllers; + +[ApiController] +[Route("/api/fwlite-release")] +[ApiExplorerSettings(GroupName = LexBoxKernel.OpenApiPublicDocumentName)] +public class FwLiteReleaseController(FwLiteReleaseService releaseService) : ControllerBase +{ + + [HttpGet("download-latest")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DownloadLatest() + { + using var activity = LexBoxActivitySource.Get().StartActivity(); + var latestRelease = await releaseService.GetLatestRelease(); + if (latestRelease is null) + { + activity?.SetStatus(ActivityStatusCode.Error, "Latest release not found"); + return NotFound(); + } + activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, latestRelease.Version); + return Redirect(latestRelease.Url); + } + + [HttpGet("latest")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesDefaultResponseType] + public async ValueTask> LatestRelease(string? appVersion = null) + { + using var activity = LexBoxActivitySource.Get().StartActivity(); + activity?.AddTag(FwLiteReleaseService.FwLiteClientVersionTag, appVersion ?? "unknown"); + var latestRelease = await releaseService.GetLatestRelease(); + activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, latestRelease?.Version); + if (latestRelease is null) return NotFound(); + return latestRelease; + } + + [HttpGet("should-update")] + public async Task> ShouldUpdate([FromQuery] string appVersion) + { + using var activity = LexBoxActivitySource.Get().StartActivity(); + 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 releaseService.InvalidateReleaseCache(); + return Ok(); + } +} 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/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/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(); diff --git a/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs b/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs new file mode 100644 index 000000000..294a2618f --- /dev/null +++ b/backend/LexBoxApi/Services/FwLiteReleases/FwLiteReleaseService.cs @@ -0,0 +1,91 @@ +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 = default) + { + return await cache.GetOrCreateAsync(GithubLatestRelease, + FetchLatestReleaseFromGithub, + new HybridCacheEntryOptions() { Expiration = TimeSpan.FromDays(1) }, + cancellationToken: token); + } + + public async ValueTask ShouldUpdate(string appVersion) + { + var latestRelease = await GetLatestRelease(); + if (latestRelease is null) return new ShouldUpdateResponse(null); + + 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); + } + + 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"); + } +} diff --git a/backend/LexCore/Entities/FwLiteRelease.cs b/backend/LexCore/Entities/FwLiteRelease.cs new file mode 100644 index 000000000..895deb5e5 --- /dev/null +++ b/backend/LexCore/Entities/FwLiteRelease.cs @@ -0,0 +1,11 @@ +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; +} 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); + } +}