From 6be271565e7c9e91047a0fff4d6678591943787d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 14 Nov 2025 11:37:26 +0100 Subject: [PATCH] Toast actions. --- Wino.Authentication/OutlookAuthenticator.cs | 26 +- .../Services/NotificationBuilder.cs | 28 +- Wino.Core.WinUI/WinoApplication.cs | 3 - Wino.Core/Services/SynchronizationManager.cs | 24 +- Wino.Core/Services/SynchronizerFactory.cs | 15 +- .../ToastNotificationActivationHandler.cs | 2 +- Wino.Mail.WinUI/App.xaml.cs | 313 +++++++++++++----- Wino.Mail.WinUI/Package.appxmanifest | 6 +- 8 files changed, 282 insertions(+), 135 deletions(-) diff --git a/Wino.Authentication/OutlookAuthenticator.cs b/Wino.Authentication/OutlookAuthenticator.cs index 7aa3113a..e06e21a9 100644 --- a/Wino.Authentication/OutlookAuthenticator.cs +++ b/Wino.Authentication/OutlookAuthenticator.cs @@ -24,12 +24,14 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator public override MailProviderType ProviderType => MailProviderType.Outlook; private readonly IPublicClientApplication _publicClientApplication; + private readonly INativeAppService _nativeAppService; private readonly IApplicationConfiguration _applicationConfiguration; public OutlookAuthenticator(INativeAppService nativeAppService, IApplicationConfiguration applicationConfiguration, IAuthenticatorConfig authenticatorConfig) : base(authenticatorConfig) { + _nativeAppService = nativeAppService; _applicationConfiguration = applicationConfiguration; var authenticationRedirectUri = nativeAppService.GetWebAuthenticationBrokerUri(); @@ -40,12 +42,24 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator ListOperatingSystemAccounts = true, }; + PublicClientApplicationBuilder outlookAppBuilder = null; - var outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId) - .WithParentActivityOrWindow(nativeAppService.GetCoreWindowHwnd) - .WithBroker(options) - .WithDefaultRedirectUri() - .WithAuthority(Authority); + // Being created from an app notification. + // This is where we avoid all interactive shit for authentication. + if (nativeAppService.GetCoreWindowHwnd == null) + { + outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId) + .WithDefaultRedirectUri() + .WithBroker(options) + .WithAuthority(Authority); + } + else + { + outlookAppBuilder = PublicClientApplicationBuilder.Create(AuthenticatorConfig.OutlookAuthenticatorClientId) + .WithBroker(options) + .WithDefaultRedirectUri() + .WithAuthority(Authority); + } _publicClientApplication = outlookAppBuilder.Build(); } @@ -99,7 +113,7 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator { await EnsureTokenCacheAttachedAsync(); - var authResult = await _publicClientApplication + AuthenticationResult authResult = await _publicClientApplication .AcquireTokenInteractive(Scope) .ExecuteAsync(); diff --git a/Wino.Core.WinUI/Services/NotificationBuilder.cs b/Wino.Core.WinUI/Services/NotificationBuilder.cs index 33c3dd84..5c1b0de3 100644 --- a/Wino.Core.WinUI/Services/NotificationBuilder.cs +++ b/Wino.Core.WinUI/Services/NotificationBuilder.cs @@ -52,23 +52,23 @@ public class NotificationBuilder : INotificationBuilder { var mailItem = await _mailService.GetSingleMailItemAsync(item.UniqueId); - //if (mailItem == null || mailItem.AssignedFolder == null) - // continue; + if (mailItem == null || mailItem.AssignedFolder == null) + continue; - //// Only create notifications for Inbox folder mails - //if (mailItem.AssignedFolder.SpecialFolderType != SpecialFolderType.Inbox) - // continue; + // Only create notifications for Inbox folder mails + if (mailItem.AssignedFolder.SpecialFolderType != SpecialFolderType.Inbox) + continue; - //// Skip folders with synchronization disabled - //if (!mailItem.AssignedFolder.IsSynchronizationEnabled) - // continue; + // Skip folders with synchronization disabled + if (!mailItem.AssignedFolder.IsSynchronizationEnabled) + continue; - //// Skip already read mails - //if (mailItem.IsRead) - //{ - // RemoveNotification(mailItem.UniqueId); - // continue; - //} + // Skip already read mails + if (mailItem.IsRead) + { + RemoveNotification(mailItem.UniqueId); + continue; + } inboxMailItems.Add(mailItem); } diff --git a/Wino.Core.WinUI/WinoApplication.cs b/Wino.Core.WinUI/WinoApplication.cs index efbb297d..a0fd5e54 100644 --- a/Wino.Core.WinUI/WinoApplication.cs +++ b/Wino.Core.WinUI/WinoApplication.cs @@ -11,7 +11,6 @@ using Microsoft.Windows.Globalization; using Nito.AsyncEx; using Serilog; using Windows.ApplicationModel.Activation; -using Windows.ApplicationModel.AppService; using Windows.ApplicationModel.Core; using Windows.Foundation.Metadata; using Windows.Storage; @@ -84,9 +83,7 @@ public abstract class WinoApplication : Application, IRecipient { yield return DatabaseService; yield return TranslationService; - yield return NewThemeService; // Initialize NewThemeService instead of old ThemeService yield return Services.GetService(); - // yield return ThemeService; // Keep old service for backward compatibility but don't initialize } public Task InitializeServicesAsync() => GetActivationServices().Select(a => a.InitializeAsync()).WhenAll(); diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index 24e3d89f..026e266e 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -42,6 +42,7 @@ public class SynchronizationManager : ISynchronizationManager /// /// Initializes the SynchronizationManager with required dependencies. /// This must be called before using any other methods. + /// Note: Synchronizers are created lazily to avoid requiring window handles during app initialization. /// /// Factory for creating synchronizers /// Service for testing IMAP connectivity @@ -65,28 +66,11 @@ public class SynchronizationManager : ISynchronizationManager _authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider)); _notificationBuilder = notificationBuilder ?? throw new ArgumentNullException(nameof(notificationBuilder)); - // Get all accounts and create synchronizers for them - var accounts = await _accountService.GetAccountsAsync(); - - foreach (var account in accounts) - { - try - { - var synchronizer = _concreteSynchronizerFactory.CreateNewSynchronizer(account); - _synchronizerCache.TryAdd(account.Id, synchronizer); - - _logger.Information("Created synchronizer for account {AccountName} ({AccountId})", - account.Name, account.Id); - } - catch (Exception ex) - { - _logger.Error(ex, "Failed to create synchronizer for account {AccountName} ({AccountId})", - account.Name, account.Id); - } - } + // DO NOT create synchronizers here to avoid requiring window handles during initialization. + // Synchronizers will be created lazily when first accessed via GetOrCreateSynchronizerAsync. _isInitialized = true; - _logger.Information("SynchronizationManager initialized with {Count} synchronizers", _synchronizerCache.Count); + _logger.Information("SynchronizationManager dependencies initialized. Synchronizers will be created lazily."); } finally { diff --git a/Wino.Core/Services/SynchronizerFactory.cs b/Wino.Core/Services/SynchronizerFactory.cs index 1cab069c..51865487 100644 --- a/Wino.Core/Services/SynchronizerFactory.cs +++ b/Wino.Core/Services/SynchronizerFactory.cs @@ -20,16 +20,14 @@ public class SynchronizerFactory : ISynchronizerFactory private readonly IOutlookChangeProcessor _outlookChangeProcessor; private readonly IGmailChangeProcessor _gmailChangeProcessor; private readonly IImapChangeProcessor _imapChangeProcessor; - private readonly IOutlookAuthenticator _outlookAuthenticator; - private readonly IGmailAuthenticator _gmailAuthenticator; + private readonly IAuthenticationProvider _authenticationProvider; private readonly List synchronizerCache = new(); public SynchronizerFactory(IOutlookChangeProcessor outlookChangeProcessor, IGmailChangeProcessor gmailChangeProcessor, IImapChangeProcessor imapChangeProcessor, - IOutlookAuthenticator outlookAuthenticator, - IGmailAuthenticator gmailAuthenticator, + IAuthenticationProvider authenticationProvider, IAccountService accountService, IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider, IApplicationConfiguration applicationConfiguration, @@ -39,8 +37,7 @@ public class SynchronizerFactory : ISynchronizerFactory _outlookChangeProcessor = outlookChangeProcessor; _gmailChangeProcessor = gmailChangeProcessor; _imapChangeProcessor = imapChangeProcessor; - _outlookAuthenticator = outlookAuthenticator; - _gmailAuthenticator = gmailAuthenticator; + _authenticationProvider = authenticationProvider; _accountService = accountService; _imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider; _applicationConfiguration = applicationConfiguration; @@ -75,9 +72,11 @@ public class SynchronizerFactory : ISynchronizerFactory switch (providerType) { case Domain.Enums.MailProviderType.Outlook: - return new OutlookSynchronizer(mailAccount, _outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory); + var outlookAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Outlook) as IOutlookAuthenticator; + return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory); case Domain.Enums.MailProviderType.Gmail: - return new GmailSynchronizer(mailAccount, _gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory); + var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator; + return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory); case Domain.Enums.MailProviderType.IMAP4: return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _imapSynchronizationStrategyProvider, _applicationConfiguration); default: diff --git a/Wino.Mail.WinUI/Activation/ToastNotificationActivationHandler.cs b/Wino.Mail.WinUI/Activation/ToastNotificationActivationHandler.cs index 436f8800..ad2fa421 100644 --- a/Wino.Mail.WinUI/Activation/ToastNotificationActivationHandler.cs +++ b/Wino.Mail.WinUI/Activation/ToastNotificationActivationHandler.cs @@ -48,7 +48,7 @@ internal class ToastNotificationActivationHandler : ActivationHandler(); + var launchProtocolService = App.Current.Services.GetRequiredService(); launchProtocolService.LaunchParameter = message; // Send the messsage anyways. Launch protocol service will be ignored if the message is picked up by subscriber shell. diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 25701b6b..ff750875 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -1,13 +1,18 @@ using System; +using System.Linq; using System.Text; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.DependencyInjection; using Microsoft.Toolkit.Uwp.Notifications; +using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; +using Wino.Core.Domain.Models.Synchronization; using Wino.Core.WinUI; using Wino.Core.WinUI.Interfaces; using Wino.Mail.Services; @@ -26,75 +31,23 @@ public partial class App : WinoApplication, IRecipient(); - var accountService = Services.GetRequiredService(); - var actionKey = toastArgs.Contains(Constants.ToastActionKey) ? toastArgs[Constants.ToastActionKey] : string.Empty; - - if (Guid.TryParse(toastArgs[Constants.ToastMailUniqueIdKey], out Guid mailItemUniqueId)) + if (activationArgs.Kind == ExtendedActivationKind.AppNotification) { - // Action triggered. - if (toastArgs.TryGetValue(Constants.ToastActionKey, out MailOperation action)) - { - // Check if the app is running. - - if (IsAppRunning()) - { - // Get the synchronizer and queue the action for the given item. - - var processor = Services.GetRequiredService(); - var delegator = Services.GetRequiredService(); - - var mailItem = await mailService.GetSingleMailItemAsync(mailItemUniqueId); - - if (mailItem != null) - { - var package = new MailOperationPreperationRequest(action, mailItem); - - await delegator.ExecuteAsync(package); - } - } - else - { - - } - } - else - { - // Notification clicked. Handle navigation. - - var account = await mailService.GetMailAccountByUniqueIdAsync(mailItemUniqueId).ConfigureAwait(false); - if (account == null) return; - - var mailItem = await mailService.GetSingleMailItemAsync(mailItemUniqueId).ConfigureAwait(false); - if (mailItem == null) return; - - var message = new AccountMenuItemExtended(mailItem.AssignedFolder.Id, mailItem); - - // Delegate this event to LaunchProtocolService so app shell can pick it up on launch if app doesn't work. - var launchProtocolService = Services.GetRequiredService(); - launchProtocolService.LaunchParameter = message; - - // Send the messsage anyways. Launch protocol service will be ignored if the message is picked up by subscriber shell. - WeakReferenceMessenger.Default.Send(message); - } + args = ((AppNotificationActivatedEventArgs)activationArgs.Data); + return true; } - if (ToastNotificationManagerCompat.WasCurrentProcessToastActivated()) - { - MainWindow.BringToFront(); - } + args = null!; + return false; } #region Dependency Injection @@ -155,38 +108,238 @@ public partial class App : WinoApplication, IRecipient(); - var configService = Services.GetRequiredService(); - var nativeAppService = Services.GetRequiredService(); + notificationManager.NotificationInvoked -= AppNotificationInvoked; + notificationManager.NotificationInvoked += AppNotificationInvoked; + notificationManager.Register(); + + // Initialize required services regardless of launch activation type. + // All activation scenarios require these services to be ready. + // Note: Theme service is initialized separately after window creation. + await InitializeServicesAsync(); _synchronizationManager = Services.GetRequiredService(); - // Load saved backdrop type before creating window - var savedBackdropType = (WindowBackdropType)configService.Get("WindowBackdropTypeKey", (int)WindowBackdropType.Mica); + // Check if launched from toast notification. + if (IsNotificationActivation(out AppNotificationActivatedEventArgs toastArgs)) + { + await HandleToastActivationAsync(toastArgs); + return; + } + + // Check if launched by startup task. + bool isStartupTaskLaunch = IsStartupTaskLaunch(); + + // Create the window (needed for system tray icon even in startup task scenario). + CreateWindow(args); + + // Initialize theme service after window creation. + // Theme service requires the window to exist to properly load and apply themes. + await NewThemeService.InitializeAsync(); + LogActivation("Theme service initialized."); + + // If startup task launch, keep window hidden (system tray only). + // Otherwise, activate the window normally. + if (isStartupTaskLaunch) + { + LogActivation("Launched by startup task. Window created but hidden (system tray only)."); + // Window is created but not activated. User can show it from system tray. + } + else + { + // Normal launch - show and activate the window. + MainWindow.Activate(); + LogActivation("Window created and activated."); + } + } + + private async void AppNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args) + => await HandleToastActivationAsync(args); + + /// + /// Handles toast notification activation scenarios. + /// + private async Task HandleToastActivationAsync(AppNotificationActivatedEventArgs toastArgs) + { + var toastArguments = ToastArguments.Parse(toastArgs.Argument); + + // Check if this is a navigation toast (user clicked the notification). + if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation action) && + Guid.TryParse(toastArguments[Constants.ToastMailUniqueIdKey], out Guid mailItemUniqueId)) + { + if (action == MailOperation.Navigate) + { + // User clicked notification - create window if needed and navigate. + await HandleToastNavigationAsync(mailItemUniqueId); + } + else + { + // User clicked action button (Mark as Read, Delete, etc.) + // Execute action without window and exit. + await HandleToastActionAsync(action, mailItemUniqueId); + } + } + } + + /// + /// Handles toast notification click for navigation. + /// Creates window if not running, sets up navigation parameter. + /// + private async Task HandleToastNavigationAsync(Guid mailItemUniqueId) + { + var mailService = Services.GetRequiredService(); + + var account = await mailService.GetMailAccountByUniqueIdAsync(mailItemUniqueId).ConfigureAwait(false); + if (account == null) return; + + var mailItem = await mailService.GetSingleMailItemAsync(mailItemUniqueId).ConfigureAwait(false); + if (mailItem == null) return; + + var message = new AccountMenuItemExtended(mailItem.AssignedFolder.Id, mailItem); + + // Store navigation parameter in LaunchProtocolService so AppShell can pick it up. + var launchProtocolService = Services.GetRequiredService(); + launchProtocolService.LaunchParameter = message; + + // Create window if not already created. + if (!IsAppRunning()) + { + // Pass null for args since we're handling toast navigation + await CreateAndActivateWindow(null!); + } + else + { + // App is already running - send message and bring window to front. + WeakReferenceMessenger.Default.Send(message); + MainWindow.BringToFront(); + } + } + + /// + /// Handles toast action button clicks (Mark as Read, Delete, etc.). + /// Executes the action without showing UI and exits the app. + /// + private async Task HandleToastActionAsync(MailOperation action, Guid mailItemUniqueId) + { + LogActivation($"Handling toast action: {action} for mail {mailItemUniqueId}"); + + var mailService = Services.GetRequiredService(); + var mailItem = await mailService.GetSingleMailItemAsync(mailItemUniqueId); + + if (mailItem == null) + { + LogActivation("Mail item not found. Exiting."); + Application.Current.Exit(); + return; + } + + var package = new MailOperationPreperationRequest(action, mailItem); + + // Check if app is already running (has a window). + if (IsAppRunning()) + { + // App is running - use the simple delegator pattern. + // The synchronization will happen in the background. + LogActivation("App is running. Queueing request via delegator."); + + var delegator = Services.GetRequiredService(); + await delegator.ExecuteAsync(package); + + // Don't exit - app continues running. + LogActivation($"Toast action {action} queued successfully."); + } + else + { + // App is not running - we need to wait for sync before exiting. + LogActivation("App is not running. Executing synchronization and waiting for completion."); + + if (_synchronizationManager == null) + { + LogActivation("Synchronization manager is not initialized. Exiting."); + Application.Current.Exit(); + return; + } + + var processor = Services.GetRequiredService(); + var notificationBuilder = Services.GetRequiredService(); + + // Prepare the requests for the action. + var requests = await processor.PrepareRequestsAsync(package); + + if (requests != null && requests.Any()) + { + // Group requests by account ID (usually just one account). + var accountIds = requests.GroupBy(a => a.Item.AssignedAccount.Id); + + foreach (var accountGroup in accountIds) + { + var accountId = accountGroup.Key; + + // Queue all requests for this account. + foreach (var request in accountGroup) + { + await _synchronizationManager.QueueRequestAsync(request, accountId, triggerSynchronization: false); + } + + // Create synchronization options to execute the queued requests. + var syncOptions = new MailSynchronizationOptions() + { + AccountId = accountId, + Type = MailSynchronizationType.ExecuteRequests + }; + + LogActivation($"Executing synchronization for account {accountId}..."); + + // Wait for synchronization to complete before exiting. + var syncResult = await _synchronizationManager.SynchronizeMailAsync(syncOptions); + + LogActivation($"Toast action {action} completed. Sync result: {syncResult.CompletedState}"); + } + + await notificationBuilder.UpdateTaskbarIconBadgeAsync(); + } + + LogActivation("Toast action handling complete. Exiting app."); + + // Exit the app after synchronization is complete. + Application.Current.Exit(); + } + } + + /// + /// Creates the main window and activates it. + /// + private async Task CreateAndActivateWindow(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + { + CreateWindow(args); + + // Initialize theme service after window is created. + await NewThemeService.InitializeAsync(); + + MainWindow.Activate(); + LogActivation("Window created and activated."); + } + + /// + /// Creates the main window without activating it. + /// Used for both normal launch and startup task launch (tray only). + /// + private void CreateWindow(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + { + LogActivation("Creating main window."); MainWindow = new ShellWindow(); + var nativeAppService = Services.GetRequiredService(); nativeAppService.GetCoreWindowHwnd = () => WinRT.Interop.WindowNative.GetWindowHandle(MainWindow); - await InitializeServicesAsync(); - - if (MainWindow is not IWinoShellWindow shellWindow) throw new ArgumentException("MainWindow must implement IWinoShellWindow"); - - bool isStartupTaskLaunch = IsStartupTaskLaunch(); + if (MainWindow is not IWinoShellWindow shellWindow) + throw new ArgumentException("MainWindow must implement IWinoShellWindow"); shellWindow.HandleAppActivation(args); - - // Do not actiavate window if launched from startup task. Keep running in the system tray. - if (!isStartupTaskLaunch) - { - MainWindow.Activate(); - } } private void RegisterRecipients() diff --git a/Wino.Mail.WinUI/Package.appxmanifest b/Wino.Mail.WinUI/Package.appxmanifest index a8a18784..d186e688 100644 --- a/Wino.Mail.WinUI/Package.appxmanifest +++ b/Wino.Mail.WinUI/Package.appxmanifest @@ -8,7 +8,7 @@ xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" - IgnorableNamespaces="uap rescap com"> + IgnorableNamespaces="uap rescap com desktop"> @@ -46,7 +46,7 @@ Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> - +