Toast actions.

This commit is contained in:
Burak Kaan Köse
2025-11-14 11:37:26 +01:00
parent 8482171bf2
commit 6be271565e
8 changed files with 282 additions and 135 deletions
+20 -6
View File
@@ -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();
+14 -14
View File
@@ -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);
}
-3
View File
@@ -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<LanguageChanged>
{
yield return DatabaseService;
yield return TranslationService;
yield return NewThemeService; // Initialize NewThemeService instead of old ThemeService
yield return Services.GetService<SynchronizationManagerInitializer>();
// yield return ThemeService; // Keep old service for backward compatibility but don't initialize
}
public Task InitializeServicesAsync() => GetActivationServices().Select(a => a.InitializeAsync()).WhenAll();
+4 -20
View File
@@ -42,6 +42,7 @@ public class SynchronizationManager : ISynchronizationManager
/// <summary>
/// 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.
/// </summary>
/// <param name="synchronizerFactory">Factory for creating synchronizers</param>
/// <param name="imapTestService">Service for testing IMAP connectivity</param>
@@ -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
{
+7 -8
View File
@@ -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<IWinoSynchronizerBase> 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:
@@ -48,7 +48,7 @@ internal class ToastNotificationActivationHandler : ActivationHandler<ToastNotif
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 = App.Current.Services.GetService<ILaunchProtocolService>();
var launchProtocolService = App.Current.Services.GetRequiredService<ILaunchProtocolService>();
launchProtocolService.LaunchParameter = message;
// Send the messsage anyways. Launch protocol service will be ignored if the message is picked up by subscriber shell.
+233 -80
View File
@@ -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<NewMailSynchronizationReq
InitializeComponent();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
ToastNotificationManagerCompat.OnActivated += ToastActivationHandler;
RegisterRecipients();
}
private async void ToastActivationHandler(ToastNotificationActivatedEventArgsCompat e)
public bool IsNotificationActivation(out AppNotificationActivatedEventArgs args)
{
// If we weren't launched by an app, launch our window like normal.
// Otherwise if launched by a toast, our OnActivated callback will be triggered.
// https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/migrate-to-windows-app-sdk/guides/toast-notifications?tabs=appsdk
var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
var toastArgs = ToastArguments.Parse(e.Argument);
var mailService = Services.GetRequiredService<IMailService>();
var accountService = Services.GetRequiredService<IAccountService>();
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<IWinoRequestProcessor>();
var delegator = Services.GetRequiredService<IWinoRequestDelegator>();
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<ILaunchProtocolService>();
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<NewMailSynchronizationReq
protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
// If it's toast activation, compat will handle it.
if (IsAppRunning()) return;
base.OnLaunched(args);
// TODO: Check app relaunch mutex before loading anything.
AppNotificationManager notificationManager = AppNotificationManager.Default;
// Initialize NewThemeService first to get backdrop settings before creating window
var newThemeService = Services.GetRequiredService<INewThemeService>();
var configService = Services.GetRequiredService<IConfigurationService>();
var nativeAppService = Services.GetRequiredService<INativeAppService>();
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<ISynchronizationManager>();
// 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);
/// <summary>
/// Handles toast notification activation scenarios.
/// </summary>
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);
}
}
}
/// <summary>
/// Handles toast notification click for navigation.
/// Creates window if not running, sets up navigation parameter.
/// </summary>
private async Task HandleToastNavigationAsync(Guid mailItemUniqueId)
{
var mailService = Services.GetRequiredService<IMailService>();
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<ILaunchProtocolService>();
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();
}
}
/// <summary>
/// Handles toast action button clicks (Mark as Read, Delete, etc.).
/// Executes the action without showing UI and exits the app.
/// </summary>
private async Task HandleToastActionAsync(MailOperation action, Guid mailItemUniqueId)
{
LogActivation($"Handling toast action: {action} for mail {mailItemUniqueId}");
var mailService = Services.GetRequiredService<IMailService>();
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<IWinoRequestDelegator>();
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<IWinoRequestProcessor>();
var notificationBuilder = Services.GetRequiredService<INotificationBuilder>();
// 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();
}
}
/// <summary>
/// Creates the main window and activates it.
/// </summary>
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.");
}
/// <summary>
/// Creates the main window without activating it.
/// Used for both normal launch and startup task launch (tray only).
/// </summary>
private void CreateWindow(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
LogActivation("Creating main window.");
MainWindow = new ShellWindow();
var nativeAppService = Services.GetRequiredService<INativeAppService>();
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()
+3 -3
View File
@@ -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">
<!-- Publisher Cache Folders -->
<Extensions>
@@ -46,7 +46,7 @@
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="Wino Mail (Preview)"
DisplayName="Wino Mail"
Description="Wino.Mail.WinUI"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
@@ -70,7 +70,7 @@
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="Wino.Mail.WinUI.exe" Arguments="-ToastActivated" DisplayName="Toast activator">
<com:ExeServer Executable="Wino.Mail.WinUI.exe" Arguments="----AppNotificationActivated:" DisplayName="Toast activator">
<com:Class Id="72c6d2d0-2538-44fe-a1b1-499f47bb1181" DisplayName="Toast activator"/>
</com:ExeServer>
</com:ComServer>