diff --git a/backend/FwLite/FwLiteShared/AppUpdate/UpdateChecker.cs b/backend/FwLite/FwLiteShared/AppUpdate/UpdateChecker.cs index ec596de0c2..b9fb370da7 100644 --- a/backend/FwLite/FwLiteShared/AppUpdate/UpdateChecker.cs +++ b/backend/FwLite/FwLiteShared/AppUpdate/UpdateChecker.cs @@ -1,33 +1,58 @@ using System.Net.Http.Json; using FwLiteShared.Events; using LexCore.Entities; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace FwLiteShared.AppUpdate; +public record AvailableUpdate(FwLiteRelease Release, bool SupportsAutoUpdate); + public class UpdateChecker( IHttpClientFactory httpClientFactory, ILogger logger, IOptions config, GlobalEventBus eventBus, - IPlatformUpdateService platformUpdateService): BackgroundService + IPlatformUpdateService platformUpdateService, + IMemoryCache cache) : BackgroundService { + private const string CacheKey = "UpdateCheck"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await TryUpdate(); } - public async Task TryUpdate(bool forceCheck = false) + public async Task TryUpdate() + { + if (!ShouldCheckForUpdate()) return; + var update = await CheckForUpdate(); + if (update is null) return; + await ApplyUpdate(update.Release); + } + + public async Task CheckForUpdate() { - if (!ShouldCheckForUpdate() && !forceCheck) return; - var response = await ShouldUpdateAsync(); + return await cache.GetOrCreateAsync(CacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + + var response = await ShouldUpdateAsync(); + platformUpdateService.LastUpdateCheck = DateTime.UtcNow; + + return response.Update + ? new AvailableUpdate(response.Release, platformUpdateService.SupportsAutoUpdate) + : null; + }); + } - platformUpdateService.LastUpdateCheck = DateTime.UtcNow; - if (!response.Update) return; + public async Task ApplyUpdate(FwLiteRelease release) + { if (ShouldPromptBeforeUpdate() && - !await platformUpdateService.RequestPermissionToUpdate(response.Release)) + !await platformUpdateService.RequestPermissionToUpdate(release)) { return; } @@ -35,7 +60,7 @@ public async Task TryUpdate(bool forceCheck = false) UpdateResult updateResult = UpdateResult.ManualUpdateRequired; if (platformUpdateService.SupportsAutoUpdate) { - updateResult = await platformUpdateService.ApplyUpdate(response.Release); + updateResult = await platformUpdateService.ApplyUpdate(release); } NotifyResult(updateResult); diff --git a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs index 254c356a57..40c96e8b4d 100644 --- a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs +++ b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs @@ -22,6 +22,7 @@ public static class FwLiteSharedKernel { public static IServiceCollection AddFwLiteShared(this IServiceCollection services, IHostEnvironment environment) { + services.AddMemoryCache(); services.AddHttpClient(); services.AddAuthHelpers(environment); services.AddLcmCrdtClient(); @@ -46,6 +47,7 @@ public static IServiceCollection AddFwLiteShared(this IServiceCollection service services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); services.TryAddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddOptions().BindConfiguration("FwLite"); services.DecorateConstructor((provider, runtime) => diff --git a/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs b/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs index c402171b62..f7102656ca 100644 --- a/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs @@ -35,6 +35,7 @@ IServiceProvider services DotnetService.MultiWindowService, DotnetService.JsEventListener, DotnetService.JsInvokableLogger, + DotnetService.UpdateService, ]; public static Type GetServiceType(DotnetService service) => service switch @@ -53,6 +54,7 @@ IServiceProvider services DotnetService.MultiWindowService => typeof(IMultiWindowService), DotnetService.JsEventListener => typeof(JsEventListener), DotnetService.JsInvokableLogger => typeof(JsInvokableLogger), + DotnetService.UpdateService => typeof(UpdateService), _ => throw new ArgumentOutOfRangeException(nameof(service), service, null) }; @@ -111,4 +113,5 @@ public enum DotnetService MultiWindowService, JsEventListener, JsInvokableLogger, + UpdateService, } diff --git a/backend/FwLite/FwLiteShared/Services/UpdateService.cs b/backend/FwLite/FwLiteShared/Services/UpdateService.cs new file mode 100644 index 0000000000..42f825f429 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Services/UpdateService.cs @@ -0,0 +1,21 @@ +using FwLiteShared.AppUpdate; +using Microsoft.JSInterop; +using Reinforced.Typings.Attributes; + +namespace FwLiteShared.Services; + +public class UpdateService(UpdateChecker updateChecker) +{ + [JSInvokable] + [TsFunction(Type = "Promise")] + public Task CheckForUpdates() + { + return updateChecker.CheckForUpdate(); + } + + [JSInvokable] + public async Task ApplyUpdate(AvailableUpdate update) + { + await updateChecker.ApplyUpdate(update.Release); + } +} diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 3a4e417562..65af2ea887 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -173,7 +173,9 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(IChange), typeof(CommitMetadata), typeof(ObjectSnapshot), - typeof(ProjectScope) + typeof(ProjectScope), + typeof(FwLiteRelease), + typeof(AvailableUpdate), ], exportBuilder => exportBuilder.WithPublicProperties()); builder.ExportAsEnum().UseString(); diff --git a/frontend/viewer/src/home/HomeView.svelte b/frontend/viewer/src/home/HomeView.svelte index 90921d9770..02212b0268 100644 --- a/frontend/viewer/src/home/HomeView.svelte +++ b/frontend/viewer/src/home/HomeView.svelte @@ -27,6 +27,7 @@ import {transitionContext} from './transitions'; import Anchor from '$lib/components/ui/anchor/anchor.svelte'; import FeedbackDialog from '$lib/about/FeedbackDialog.svelte'; + import UpdateDialog from '$lib/about/UpdateDialog.svelte'; import DeleteDialog from '$lib/entry-editor/DeleteDialog.svelte'; import {SYNC_DIALOG_QUERY_PARAM} from '../project/SyncDialog.svelte'; @@ -132,6 +133,7 @@ } let feedbackOpen = $state(false); + let updateDialogOpen = $state(false); let deleteDialog = $state(); @@ -160,6 +162,9 @@ + updateDialogOpen = true} icon="i-mdi-update"> + {$t`Check for Updates`} + feedbackOpen = true} icon="i-mdi-message"> {$t`Feedback & Support`} @@ -170,6 +175,7 @@ + diff --git a/frontend/viewer/src/lib/about/UpdateDialog.svelte b/frontend/viewer/src/lib/about/UpdateDialog.svelte new file mode 100644 index 0000000000..1515ced964 --- /dev/null +++ b/frontend/viewer/src/lib/about/UpdateDialog.svelte @@ -0,0 +1,134 @@ + + + +
+
+
+ {$t`Current version:`} {appVersion} +
+
+ {$t`Platform:`} {config.os} +
+
+ + {#if checkPromise} + {#await checkPromise} +
+ +
+ {$t`Checking for updates...`} +
+
+ {:then availableUpdate} + {#if installSuccess} +
+ +
+ {$t`Update installed successfully! Please restart the application.`} +
+
+ {:else if availableUpdate} +
+ +
+ {$t`Update available: ${availableUpdate.release.version}`} +
+
+ {:else} +
+ +
+ {$t`You are running the latest version.`} +
+
+ {/if} + {:catch error} +
+ +
+ {$t`Error checking for updates: ${error instanceof Error ? error.message : String(error)}`} +
+
+ {/await} + {/if} + + {#if checkPromise} + {#await checkPromise then availableUpdate} + {#if !availableUpdate || installSuccess} + + {:else if availableUpdate.supportsAutoUpdate} + {#if installPromise} + {#await installPromise} + + {:then} + + {:catch error} + +
+ {$t`Error: ${error instanceof Error ? error.message : String(error)}`} +
+ {/await} + {:else} + + {/if} + {:else} + + {/if} + {:catch} + + {/await} + {:else} + + {/if} +
+
diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/AppUpdate/IAvailableUpdate.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/AppUpdate/IAvailableUpdate.ts new file mode 100644 index 0000000000..7702edd5fa --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/AppUpdate/IAvailableUpdate.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {IFwLiteRelease} from '../../LexCore/Entities/IFwLiteRelease'; + +export interface IAvailableUpdate +{ + release: IFwLiteRelease; + supportsAutoUpdate: boolean; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts index 70728a9855..9afac706f0 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts @@ -17,6 +17,7 @@ export enum DotnetService { TestingService = "TestingService", MultiWindowService = "MultiWindowService", JsEventListener = "JsEventListener", - JsInvokableLogger = "JsInvokableLogger" + JsInvokableLogger = "JsInvokableLogger", + UpdateService = "UpdateService" } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IUpdateService.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IUpdateService.ts new file mode 100644 index 0000000000..30e5765b62 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IUpdateService.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {IAvailableUpdate} from '../AppUpdate/IAvailableUpdate'; + +export interface IUpdateService +{ + checkForUpdates() : Promise; + applyUpdate(update: IAvailableUpdate) : Promise; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LexCore/Entities/IFwLiteRelease.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LexCore/Entities/IFwLiteRelease.ts new file mode 100644 index 0000000000..4b659c645a --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LexCore/Entities/IFwLiteRelease.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +export interface IFwLiteRelease +{ + version: string; + url: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/services/service-provider.ts b/frontend/viewer/src/lib/services/service-provider.ts index 5874a7ed2f..107be7cbc1 100644 --- a/frontend/viewer/src/lib/services/service-provider.ts +++ b/frontend/viewer/src/lib/services/service-provider.ts @@ -19,6 +19,8 @@ import type {ISyncServiceJsInvokable} from '$lib/dotnet-types/generated-types/Fw import {useProjectContext} from '../project-context.svelte'; import type {IJsInvokableLogger} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IJsInvokableLogger'; +import type {IUpdateService} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IUpdateService'; + export type ServiceKey = keyof LexboxServiceRegistry; export type LexboxServiceRegistry = { [DotnetService.MiniLcmApi]: IMiniLcmJsInvokable, @@ -35,6 +37,7 @@ export type LexboxServiceRegistry = { [DotnetService.MultiWindowService]: IMultiWindowService, [DotnetService.JsEventListener]: IJsEventListener, [DotnetService.JsInvokableLogger]: IJsInvokableLogger, + [DotnetService.UpdateService]: IUpdateService, }; export const SERVICE_KEYS = Object.values(DotnetService); @@ -122,6 +125,10 @@ export function useTroubleshootingService(): ITroubleshootingService | undefined return window.lexbox.ServiceProvider.tryGetService(DotnetService.TroubleshootingService); } +export function useUpdateService(): IUpdateService { + return window.lexbox.ServiceProvider.getService(DotnetService.UpdateService); +} + export function useService(key: K): LexboxServiceRegistry[K] { return window.lexbox.ServiceProvider.getService(key); }