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);