diff --git a/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs b/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs index 4ebbf51797..b77f39ffcf 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs +++ b/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs @@ -4,6 +4,7 @@ using FwLiteShared.Services; using LcmCrdt; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Logging; @@ -66,6 +67,7 @@ public static void AddFwLiteMauiServices(this IServiceCollection services, #endif #if ANDROID services.Configure(config => config.ParentActivityOrWindow = Platform.CurrentActivity); + services.Replace(ServiceDescriptor.Singleton()); #endif services.AddSingleton(); diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidForegroundWorkHost.cs b/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidForegroundWorkHost.cs new file mode 100644 index 0000000000..b3f4daed8c --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidForegroundWorkHost.cs @@ -0,0 +1,86 @@ +using Android; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using AndroidX.Core.App; +using AndroidX.Core.Content; +using FwLiteShared.Services; +using Microsoft.Extensions.Logging; +using AndroidApplication = Android.App.Application; + +namespace FwLiteMaui; + +public sealed class AndroidForegroundWorkHost(ILogger logger) : ILongRunningWorkHost +{ + private const int NotificationPermissionRequestCode = 2300; + private readonly Lock wakeLockLock = new(); + private PowerManager.WakeLock? wakeLock; + + public Task WorkStartedAsync(LongRunningWorkRequest request, CancellationToken cancellationToken) + { + var context = Platform.AppContext ?? AndroidApplication.Context; + RequestNotificationPermissionIfNeeded(context); + + var intent = new Intent(context, typeof(ForegroundWorkService)) + .PutExtra(ForegroundWorkService.TitleExtra, request.Title) + .PutExtra(ForegroundWorkService.TextExtra, request.NotificationText); + ContextCompat.StartForegroundService(context, intent); + AcquireWakeLock(context); + return Task.CompletedTask; + } + + public Task WorkQueueDrainedAsync(CancellationToken cancellationToken) + { + ReleaseWakeLock(); + + var context = Platform.AppContext ?? AndroidApplication.Context; + context.StopService(new Intent(context, typeof(ForegroundWorkService))); + return Task.CompletedTask; + } + + private void RequestNotificationPermissionIfNeeded(Context context) + { + if (!OperatingSystem.IsAndroidVersionAtLeast(33)) return; + if (ContextCompat.CheckSelfPermission(context, Manifest.Permission.PostNotifications) == Permission.Granted) return; + + var activity = Platform.CurrentActivity; + if (activity is null) + { + logger.LogWarning("Unable to request Android notification permission because no current activity is available"); + return; + } + + ActivityCompat.RequestPermissions( + activity, + [Manifest.Permission.PostNotifications], + NotificationPermissionRequestCode); + } + + private void AcquireWakeLock(Context context) + { + lock (wakeLockLock) + { + if (wakeLock?.IsHeld == true) return; + + var powerManager = (PowerManager?)context.GetSystemService(Context.PowerService); + wakeLock = powerManager?.NewWakeLock(WakeLockFlags.Partial, "FwLite:LongRunningWork"); + wakeLock?.Acquire(); + } + } + + private void ReleaseWakeLock() + { + lock (wakeLockLock) + { + try + { + if (wakeLock?.IsHeld == true) wakeLock.Release(); + } + finally + { + wakeLock?.Dispose(); + wakeLock = null; + } + } + } +} diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidManifest.xml b/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidManifest.xml index 222b960ad0..92841c5d95 100644 --- a/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidManifest.xml +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidManifest.xml @@ -1,9 +1,15 @@ - + + + + + + + diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/ForegroundWorkService.cs b/backend/FwLite/FwLiteMaui/Platforms/Android/ForegroundWorkService.cs new file mode 100644 index 0000000000..fd8284a0c5 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/ForegroundWorkService.cs @@ -0,0 +1,87 @@ +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using AndroidX.Core.App; + +namespace FwLiteMaui; + +[Service(Name = ServiceName, Exported = false, ForegroundServiceType = ForegroundService.TypeDataSync)] +public sealed class ForegroundWorkService : Service +{ + public const string ServiceName = "org.sil.FwLiteMaui.ForegroundWorkService"; + public const string TitleExtra = "org.sil.FwLiteMaui.ForegroundWorkService.Title"; + public const string TextExtra = "org.sil.FwLiteMaui.ForegroundWorkService.Text"; + public const string ChannelId = "fw-lite-long-running-work"; + public const int NotificationId = 2030; + + public override IBinder? OnBind(Intent? intent) => null; + + public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId) + { + var title = intent?.GetStringExtra(TitleExtra) ?? "FieldWorks Lite is working"; + var text = intent?.GetStringExtra(TextExtra) ?? "FieldWorks Lite is completing work"; + EnsureNotificationChannel(); + var notification = BuildNotification(title, text); + + if (OperatingSystem.IsAndroidVersionAtLeast(29)) + { + StartForeground(NotificationId, notification, ForegroundService.TypeDataSync); + } + else + { + StartForeground(NotificationId, notification); + } + + return StartCommandResult.NotSticky; + } + + public override void OnDestroy() + { + StopForeground(StopForegroundFlags.Remove); + base.OnDestroy(); + } + + private void EnsureNotificationChannel() + { + if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return; + + var notificationManager = (NotificationManager?)GetSystemService(NotificationService); + var existingChannel = notificationManager?.GetNotificationChannel(ChannelId); + if (existingChannel is not null) return; + + var channel = new NotificationChannel( + ChannelId, + "Long-running FieldWorks Lite work", + NotificationImportance.Low) + { + Description = "Shows progress for downloads and other long-running FieldWorks Lite work." + }; + notificationManager?.CreateNotificationChannel(channel); + } + + private Notification BuildNotification(string title, string text) + { + var launchIntent = PackageManager?.GetLaunchIntentForPackage(PackageName ?? string.Empty); + var pendingIntent = launchIntent is null + ? null + : PendingIntent.GetActivity( + this, + 0, + launchIntent, + PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent); + + var builder = new NotificationCompat.Builder(this, ChannelId); + builder.SetSmallIcon(Resource.Drawable.ic_notification); + builder.SetContentTitle(title); + builder.SetContentText(text); + builder.SetOngoing(true); + builder.SetOnlyAlertOnce(true); + builder.SetCategory(NotificationCompat.CategoryStatus); + builder.SetPriority((int)NotificationPriority.Low); + + if (pendingIntent is not null) builder.SetContentIntent(pendingIntent); + + return builder.Build() ?? throw new InvalidOperationException("Unable to create foreground work notification"); + } +} diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/README.md b/backend/FwLite/FwLiteMaui/Platforms/Android/README.md new file mode 100644 index 0000000000..09ca800185 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/README.md @@ -0,0 +1,13 @@ +# Android Long-Running Work + +FwLite uses `AndroidForegroundWorkHost` and `ForegroundWorkService` as a generic foreground-service host for user-visible long-running work. The service only maintains Android foreground-service state and notification lifecycle; queued work continues to run through the shared .NET `ILongRunningWorkQueue`. + +If the foreground service or wake lock fails to start, work still continues (fail open) so downloads are not blocked. A global `UserNotification` event is published so the UI can show a copyable error toast for bug reports. + +## Manual Sleep Test + +1. Start FwLite on an Android device or emulator. +2. Start downloading a large project from the normal project download UI. +3. Turn the screen off and wait long enough to exceed the normal screen-sleep window. +4. Turn the screen back on. +5. Verify the project completed or surfaced a real error, rather than silently stopping while the screen was off. diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/Resources/drawable/ic_notification.xml b/backend/FwLite/FwLiteMaui/Platforms/Android/Resources/drawable/ic_notification.xml new file mode 100644 index 0000000000..c3529bbcaf --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/Resources/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/backend/FwLite/FwLiteShared.Tests/Services/InProcessLongRunningWorkQueueTests.cs b/backend/FwLite/FwLiteShared.Tests/Services/InProcessLongRunningWorkQueueTests.cs new file mode 100644 index 0000000000..aca5449aac --- /dev/null +++ b/backend/FwLite/FwLiteShared.Tests/Services/InProcessLongRunningWorkQueueTests.cs @@ -0,0 +1,146 @@ +using System.Collections.Concurrent; +using System.Reactive.Linq; +using FwLiteShared.Events; +using FwLiteShared.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace FwLiteShared.Tests.Services; + +public class InProcessLongRunningWorkQueueTests +{ + [Fact] + public async Task RunsQueuedWorkSerially() + { + var host = new FakeLongRunningWorkHost(); + var queue = CreateQueue(host); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observed = new ConcurrentQueue(); + + var first = queue.EnqueueAsync(Request("First"), async _ => + { + observed.Enqueue("first-start"); + firstStarted.SetResult(); + await releaseFirst.Task.WaitAsync(TimeSpan.FromSeconds(5)); + observed.Enqueue("first-end"); + }); + await firstStarted.Task; + + var second = queue.EnqueueAsync(Request("Second"), _ => + { + observed.Enqueue("second-start"); + secondStarted.SetResult(); + return Task.CompletedTask; + }); + + var earlySecondStart = await Task.WhenAny(secondStarted.Task, Task.Delay(100)); + earlySecondStart.Should().NotBe(secondStarted.Task); + + releaseFirst.SetResult(); + await Task.WhenAll(first, second); + + observed.Should().Equal("first-start", "first-end", "second-start"); + } + + [Fact] + public async Task PropagatesWorkExceptionAndContinuesWithNextItem() + { + var queue = CreateQueue(); + + var failedWork = async () => await queue.EnqueueAsync( + Request("Fails"), + _ => throw new InvalidOperationException("boom")); + + await failedWork.Should().ThrowAsync().WithMessage("boom"); + + var result = await queue.EnqueueAsync(Request("Succeeds"), _ => Task.FromResult(42)); + result.Should().Be(42); + } + + [Fact] + public async Task KeepsHostActiveUntilQueuedWorkDrains() + { + var host = new FakeLongRunningWorkHost(); + var queue = CreateQueue(host); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var first = queue.EnqueueAsync(Request("First"), async _ => + { + firstStarted.SetResult(); + await releaseFirst.Task.WaitAsync(TimeSpan.FromSeconds(5)); + }); + await firstStarted.Task; + + var second = queue.EnqueueAsync(Request("Second"), _ => Task.CompletedTask); + releaseFirst.SetResult(); + await Task.WhenAll(first, second); + + host.StartedTitles.Should().Equal("First", "Second"); + host.DrainedCount.Should().Be(1); + } + + [Fact] + public async Task HostStartFailure_ContinuesWorkAndPublishesEvent() + { + using var eventBus = new GlobalEventBus(NullLogger.Instance); + UserNotificationEvent? published = null; + using var _ = eventBus.OnGlobalEvent.OfType() + .Subscribe(e => published = e); + + var host = new FakeLongRunningWorkHost + { + StartException = new InvalidOperationException("foreground service failed") + }; + var queue = CreateQueue(host, eventBus); + var ran = false; + + await queue.EnqueueAsync(Request("Downloading project demo"), _ => + { + ran = true; + return Task.CompletedTask; + }); + + ran.Should().BeTrue(); + published.Should().NotBeNull(); + published!.Message.Should().Be("Background work protection failed"); + published.NotificationType.Should().Be(UserNotificationType.Error); + published.Duration.Should().Be(UserNotificationDuration.Infinite); + published.Description.Should().Contain("Downloading project demo"); + published.ClipboardText.Should().Contain("foreground service failed"); + host.DrainedCount.Should().Be(1); + } + + private static InProcessLongRunningWorkQueue CreateQueue( + ILongRunningWorkHost? host = null, + GlobalEventBus? eventBus = null) + { + return new InProcessLongRunningWorkQueue( + host ?? new FakeLongRunningWorkHost(), + eventBus ?? new GlobalEventBus(NullLogger.Instance), + NullLogger.Instance); + } + + private static LongRunningWorkRequest Request(string title) => new(title, $"{title} notification"); + + private sealed class FakeLongRunningWorkHost : ILongRunningWorkHost + { + public List StartedTitles { get; } = []; + public int DrainedCount { get; private set; } + public Exception? StartException { get; init; } + + public Task WorkStartedAsync(LongRunningWorkRequest request, CancellationToken cancellationToken) + { + StartedTitles.Add(request.Title); + if (StartException is not null) throw StartException; + return Task.CompletedTask; + } + + public Task WorkQueueDrainedAsync(CancellationToken cancellationToken) + { + DrainedCount++; + return Task.CompletedTask; + } + } +} diff --git a/backend/FwLite/FwLiteShared/Events/IFwEvent.cs b/backend/FwLite/FwLiteShared/Events/IFwEvent.cs index 2fb0beffc5..5b097e600c 100644 --- a/backend/FwLite/FwLiteShared/Events/IFwEvent.cs +++ b/backend/FwLite/FwLiteShared/Events/IFwEvent.cs @@ -9,6 +9,7 @@ namespace FwLiteShared.Events; [JsonDerivedType(typeof(SyncEvent), nameof(SyncEvent))] [JsonDerivedType(typeof(AppUpdateEvent), nameof(AppUpdateEvent))] [JsonDerivedType(typeof(AppUpdateProgressEvent), nameof(AppUpdateProgressEvent))] +[JsonDerivedType(typeof(UserNotificationEvent), nameof(UserNotificationEvent))] public interface IFwEvent { FwEventType Type { get; } @@ -25,4 +26,5 @@ public enum FwEventType Sync, AppUpdate, AppUpdateProgress, + UserNotification, } diff --git a/backend/FwLite/FwLiteShared/Events/UserNotificationEvent.cs b/backend/FwLite/FwLiteShared/Events/UserNotificationEvent.cs new file mode 100644 index 0000000000..1602820bbe --- /dev/null +++ b/backend/FwLite/FwLiteShared/Events/UserNotificationEvent.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace FwLiteShared.Events; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserNotificationType +{ + Plain, + Success, + Error, + Info, + Warning, +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserNotificationDuration +{ + Min, + Short, + Long, + Infinite, +} + +public class UserNotificationEvent( + string message, + UserNotificationType notificationType = UserNotificationType.Plain, + UserNotificationDuration duration = UserNotificationDuration.Infinite, + string? description = null, + string? clipboardText = null) : IFwEvent +{ + public string Message { get; } = message; + public string? Description { get; } = description; + public UserNotificationType NotificationType { get; } = notificationType; + public UserNotificationDuration Duration { get; } = duration; + public string? ClipboardText { get; } = clipboardText; + public FwEventType Type => FwEventType.UserNotification; + public bool IsGlobal => true; +} diff --git a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs index 4c37077ffd..c0700cdaa4 100644 --- a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs +++ b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs @@ -37,6 +37,8 @@ public static IServiceCollection AddFwLiteShared(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.AddScoped(); services.AddScoped(); //this is scoped so that there will be once instance per blazor circuit, this prevents issues where the same instance is used when reloading the page. diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs index 1815db8b8e..8b18bb9d8e 100644 --- a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs +++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using FwLiteShared.Auth; +using FwLiteShared.Services; using FwLiteShared.Sync; using LcmCrdt; using LexCore.Entities; @@ -43,7 +44,8 @@ public record ServerProjects(LexboxServer Server, ProjectModel[] Projects, bool public class CombinedProjectsService(LexboxProjectService lexboxProjectService, CrdtProjectsService crdtProjectsService, IEnumerable projectProviders, - OAuthClientFactory oAuthClientFactory) + OAuthClientFactory oAuthClientFactory, + ILongRunningWorkQueue longRunningWorkQueue) { private IProjectProvider? FwDataProjectProvider => projectProviders.FirstOrDefault(p => p.DataFormat == ProjectDataFormat.FwData); [JSInvokable] @@ -193,17 +195,22 @@ public async Task DownloadProject(ProjectModel project) var server = project.Server ?? throw new ArgumentNullException($"{nameof(project.Server)} is null for project {project.Code}"); var projectId = project.Id ?? throw new ArgumentNullException($"{nameof(project.Id)} is null for project {project.Code}"); var currentUser = await oAuthClientFactory.GetClient(server).GetCurrentUser(); - await Task.Run(async () => await crdtProjectsService.CreateProject(new(project.Name, - project.Code, - projectId, - server.Authority, - async (provider, project) => - { - await provider.GetRequiredService().ExecuteSync(true); - }, - AuthenticatedUser: currentUser?.Name, - AuthenticatedUserId: currentUser?.Id, - Role: ToRole(project.Role)))); + await longRunningWorkQueue.EnqueueAsync( + new LongRunningWorkRequest( + $"Downloading project {project.Code}", + "FieldWorks Lite is downloading a project", + LongRunningWorkCategory.DataSync), + async _ => await crdtProjectsService.CreateProject(new(project.Name, + project.Code, + projectId, + server.Authority, + async (provider, project) => + { + await provider.GetRequiredService().ExecuteSync(true); + }, + AuthenticatedUser: currentUser?.Name, + AuthenticatedUserId: currentUser?.Id, + Role: ToRole(project.Role)))); } [JSInvokable] diff --git a/backend/FwLite/FwLiteShared/Services/LongRunningWorkQueue.cs b/backend/FwLite/FwLiteShared/Services/LongRunningWorkQueue.cs new file mode 100644 index 0000000000..f1812618d0 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Services/LongRunningWorkQueue.cs @@ -0,0 +1,156 @@ +using FwLiteShared.Events; +using Microsoft.Extensions.Logging; + +namespace FwLiteShared.Services; + +public enum LongRunningWorkCategory +{ + General, + DataSync, +} + +public enum LongRunningWorkCancellationBehavior +{ + RunToCompletion, + CancelWhenCallerCancels, +} + +public sealed record LongRunningWorkRequest( + string Title, + string NotificationText, + LongRunningWorkCategory Category = LongRunningWorkCategory.General, + LongRunningWorkCancellationBehavior CancellationBehavior = LongRunningWorkCancellationBehavior.RunToCompletion); + +public interface ILongRunningWorkQueue +{ + Task EnqueueAsync( + LongRunningWorkRequest request, + Func work, + CancellationToken cancellationToken = default); + + Task EnqueueAsync( + LongRunningWorkRequest request, + Func> work, + CancellationToken cancellationToken = default); +} + +public interface ILongRunningWorkHost +{ + Task WorkStartedAsync(LongRunningWorkRequest request, CancellationToken cancellationToken); + Task WorkQueueDrainedAsync(CancellationToken cancellationToken); +} + +public sealed class NoOpLongRunningWorkHost : ILongRunningWorkHost +{ + public Task WorkStartedAsync(LongRunningWorkRequest request, CancellationToken cancellationToken) => Task.CompletedTask; + public Task WorkQueueDrainedAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class InProcessLongRunningWorkQueue( + ILongRunningWorkHost workHost, + GlobalEventBus globalEventBus, + ILogger logger) : ILongRunningWorkQueue +{ + private readonly SemaphoreSlim queueLock = new(1, 1); + private int queuedOrRunningCount; + private int hostActive; + + public async Task EnqueueAsync( + LongRunningWorkRequest request, + Func work, + CancellationToken cancellationToken = default) + { + await EnqueueAsync(request, + async token => + { + await work(token); + return null; + }, + cancellationToken); + } + + public async Task EnqueueAsync( + LongRunningWorkRequest request, + Func> work, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(work); + + Interlocked.Increment(ref queuedOrRunningCount); + var lockTaken = false; + try + { + var waitToken = request.CancellationBehavior == LongRunningWorkCancellationBehavior.CancelWhenCallerCancels + ? cancellationToken + : CancellationToken.None; + await queueLock.WaitAsync(waitToken); + lockTaken = true; + + var workToken = request.CancellationBehavior == LongRunningWorkCancellationBehavior.CancelWhenCallerCancels + ? cancellationToken + : CancellationToken.None; + + await NotifyWorkStarted(request, workToken); + return await Task.Run(() => work(workToken), workToken); + } + finally + { + // Drain while still holding the lock so StopService / wake-lock release + // cannot race a following StartForegroundService / acquire. + if (lockTaken) + { + await CompleteQueuedSlotUnderLock(); + queueLock.Release(); + } + else + { + Interlocked.Decrement(ref queuedOrRunningCount); + } + } + } + + private async Task NotifyWorkStarted(LongRunningWorkRequest request, CancellationToken cancellationToken) + { + Interlocked.Exchange(ref hostActive, 1); + try + { + await workHost.WorkStartedAsync(request, cancellationToken); + } + catch (Exception e) + { + // Fail open: work continues without foreground/wake-lock protection so downloads still + // succeed when the host fails. Surface the failure so users can report it. + logger.LogError(e, "Error starting long-running work host for {WorkTitle}", request.Title); + try + { + globalEventBus.PublishEvent(new UserNotificationEvent( + message: "Background work protection failed", + notificationType: UserNotificationType.Error, + duration: UserNotificationDuration.Infinite, + description: + $"\"{request.Title}\" will continue, but may stop if the screen turns off. Please report this error.", + clipboardText: e.ToString())); + } + catch (Exception publishError) + { + logger.LogError(publishError, "Failed to publish long-running work host failure event"); + } + } + } + + private async Task CompleteQueuedSlotUnderLock() + { + if (Interlocked.Decrement(ref queuedOrRunningCount) != 0) return; + if (Interlocked.Exchange(ref hostActive, 0) == 0) return; + + try + { + await workHost.WorkQueueDrainedAsync(CancellationToken.None); + } + catch (Exception e) + { + logger.LogError(e, "Error stopping long-running work host"); + } + } +} diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 5f25af91d7..8fe5168f03 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -200,6 +200,8 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(); + builder.ExportAsEnum().UseString(); + builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(false); var eventJsAttrs = typeof(IFwEvent).GetCustomAttributes(); builder.ExportAsInterfaces( diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType.ts index 2fecdfe09b..3e160cab26 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType.ts @@ -9,6 +9,7 @@ export enum FwEventType { ProjectEvent = "ProjectEvent", Sync = "Sync", AppUpdate = "AppUpdate", - AppUpdateProgress = "AppUpdateProgress" + AppUpdateProgress = "AppUpdateProgress", + UserNotification = "UserNotification" } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/IUserNotificationEvent.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/IUserNotificationEvent.ts new file mode 100644 index 0000000000..b5d04c2e48 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/IUserNotificationEvent.ts @@ -0,0 +1,21 @@ +/* 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 {IFwEvent} from './IFwEvent'; +import type {UserNotificationType} from './UserNotificationType'; +import type {UserNotificationDuration} from './UserNotificationDuration'; +import type {FwEventType} from './FwEventType'; + +export interface IUserNotificationEvent extends IFwEvent +{ + message: string; + description?: string; + notificationType: UserNotificationType; + duration: UserNotificationDuration; + clipboardText?: string; + type: FwEventType; + isGlobal: boolean; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/UserNotificationDuration.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/UserNotificationDuration.ts new file mode 100644 index 0000000000..a68d1b2e1d --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/UserNotificationDuration.ts @@ -0,0 +1,12 @@ +/* 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 enum UserNotificationDuration { + Min = "Min", + Short = "Short", + Long = "Long", + Infinite = "Infinite" +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/UserNotificationType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/UserNotificationType.ts new file mode 100644 index 0000000000..03e969e15a --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/UserNotificationType.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. + +export enum UserNotificationType { + Plain = "Plain", + Success = "Success", + Error = "Error", + Info = "Info", + Warning = "Warning" +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/index.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/index.ts index 346eccccba..68dda68893 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/index.ts @@ -5,5 +5,8 @@ export * from './IAuthenticationChangedEvent'; export * from './IEntriesChangedEvent'; export * from './IFwEvent'; export * from './IJsEventListener'; +export * from './IUserNotificationEvent'; export * from './IProjectEvent'; export * from './ISyncEvent'; +export * from './UserNotificationDuration'; +export * from './UserNotificationType'; diff --git a/frontend/viewer/src/lib/notifications/NotificationOutlet.svelte b/frontend/viewer/src/lib/notifications/NotificationOutlet.svelte index 14a517d238..f43410879c 100644 --- a/frontend/viewer/src/lib/notifications/NotificationOutlet.svelte +++ b/frontend/viewer/src/lib/notifications/NotificationOutlet.svelte @@ -2,12 +2,30 @@ import {AppNotification} from './notifications'; import {useEventBus} from '$lib/services/event-bus'; import type {IAppUpdateEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IAppUpdateEvent'; + import type {IUserNotificationEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IUserNotificationEvent'; import {FwEventType} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType'; + import {UserNotificationType} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/UserNotificationType'; + import {UserNotificationDuration} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/UserNotificationDuration'; import {UpdateResult} from '$lib/dotnet-types/generated-types/FwLiteShared/AppUpdate/UpdateResult'; import {t} from 'svelte-i18n-lingui'; import {Toaster} from '$lib/components/ui/sonner'; import {openReleaseUrl} from '$lib/updates/utils'; + const notificationTypes = { + [UserNotificationType.Plain]: 'plain', + [UserNotificationType.Success]: 'success', + [UserNotificationType.Error]: 'error', + [UserNotificationType.Info]: 'info', + [UserNotificationType.Warning]: 'warning', + } as const; + + const notificationDurations = { + [UserNotificationDuration.Min]: 'min', + [UserNotificationDuration.Short]: 'short', + [UserNotificationDuration.Long]: 'long', + [UserNotificationDuration.Infinite]: 'infinite', + } as const; + const eventBus = useEventBus(); eventBus.onEventType(FwEventType.AppUpdate, event => { @@ -25,6 +43,19 @@ ); } }, {includeLast: true}); + + eventBus.onEventType(FwEventType.UserNotification, event => { + const {message, description, notificationType, duration, clipboardText} = event; + if (notificationType === UserNotificationType.Error && clipboardText) { + AppNotification.error(message, description, clipboardText); + } else { + AppNotification.display(message, { + type: notificationTypes[notificationType], + timeout: notificationDurations[duration], + description, + }); + } + });