Auto sync trigger and cancellation support.

This commit is contained in:
Burak Kaan Köse
2026-02-11 14:50:59 +01:00
parent 96d2efb3f0
commit 96dcdc8e03
4 changed files with 205 additions and 8 deletions
@@ -81,6 +81,11 @@ public interface ISynchronizationManager
/// </summary>
IWinoSynchronizerBase CreateSynchronizerForAccount(MailAccount account);
/// <summary>
/// Cancels ongoing synchronizations for the given account.
/// </summary>
Task CancelSynchronizationsAsync(Guid accountId);
/// <summary>
/// Destroys the synchronizer for the given account.
/// </summary>
+60 -2
View File
@@ -26,6 +26,7 @@ public class SynchronizationManager : ISynchronizationManager
public static SynchronizationManager Instance => _instance.Value;
private readonly ConcurrentDictionary<Guid, IWinoSynchronizerBase> _synchronizerCache = new();
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _accountSynchronizationCancellationSources = new();
private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
private readonly ILogger _logger = Log.ForContext<SynchronizationManager>();
@@ -141,9 +142,14 @@ public class SynchronizationManager : ISynchronizationManager
_logger.Information("Starting mail synchronization for account {AccountId} with type {SyncType}",
options.AccountId, options.Type);
var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource());
using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
accountCancellationSource.Token);
try
{
var result = await synchronizer.SynchronizeMailsAsync(options, cancellationToken);
var result = await synchronizer.SynchronizeMailsAsync(options, linkedCancellationTokenSource.Token);
_logger.Information("Mail synchronization completed for account {AccountId} with state {State}",
options.AccountId, result.CompletedState);
@@ -156,6 +162,11 @@ public class SynchronizationManager : ISynchronizationManager
return result;
}
catch (OperationCanceledException)
{
_logger.Information("Mail synchronization canceled for account {AccountId}", options.AccountId);
return MailSynchronizationResult.Canceled;
}
catch (AuthenticationAttentionException authEx)
{
_logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId);
@@ -350,9 +361,14 @@ public class SynchronizationManager : ISynchronizationManager
_logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}",
options.AccountId, options.Type);
var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource());
using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
accountCancellationSource.Token);
try
{
var result = await synchronizer.SynchronizeCalendarEventsAsync(options, cancellationToken);
var result = await synchronizer.SynchronizeCalendarEventsAsync(options, linkedCancellationTokenSource.Token);
_logger.Information("Calendar synchronization completed for account {AccountId} with state {State}",
options.AccountId, result.CompletedState);
@@ -363,6 +379,11 @@ public class SynchronizationManager : ISynchronizationManager
return result;
}
catch (OperationCanceledException)
{
_logger.Information("Calendar synchronization canceled for account {AccountId}", options.AccountId);
return CalendarSynchronizationResult.Canceled;
}
catch (AuthenticationAttentionException authEx)
{
_logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId);
@@ -491,6 +512,38 @@ public class SynchronizationManager : ISynchronizationManager
}
}
/// <summary>
/// Cancels all in-flight synchronizations for the given account.
/// </summary>
/// <param name="accountId">Account ID to cancel synchronizations for</param>
public Task CancelSynchronizationsAsync(Guid accountId)
{
EnsureInitialized();
if (_accountSynchronizationCancellationSources.TryRemove(accountId, out var cancellationSource))
{
try
{
if (!cancellationSource.IsCancellationRequested)
{
cancellationSource.Cancel();
}
}
catch (ObjectDisposedException)
{
// no-op
}
finally
{
cancellationSource.Dispose();
}
_logger.Information("Canceled ongoing synchronizations for account {AccountId}", accountId);
}
return Task.CompletedTask;
}
/// <summary>
/// Destroys the synchronizer for the given account.
/// </summary>
@@ -498,6 +551,7 @@ public class SynchronizationManager : ISynchronizationManager
public async Task DestroySynchronizerAsync(Guid accountId)
{
EnsureInitialized();
await CancelSynchronizationsAsync(accountId);
if (_synchronizerCache.TryRemove(accountId, out var synchronizer))
{
@@ -506,6 +560,10 @@ public class SynchronizationManager : ISynchronizationManager
await synchronizer.KillSynchronizerAsync();
_logger.Information("Destroyed synchronizer for account {AccountId}", accountId);
}
catch (OperationCanceledException)
{
_logger.Information("Synchronizer destruction canceled for account {AccountId}", accountId);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to destroy synchronizer for account {AccountId}", accountId);
+140
View File
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
@@ -32,7 +34,13 @@ public partial class App : WinoApplication,
IRecipient<NewMailSynchronizationRequested>,
IRecipient<NewCalendarSynchronizationRequested>
{
private const int InboxSyncsPerFullSync = 20;
private ISynchronizationManager? _synchronizationManager;
private IPreferencesService? _preferencesService;
private IAccountService? _accountService;
private CancellationTokenSource? _autoSynchronizationLoopCts;
private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1);
private readonly Dictionary<Guid, int> _inboxSyncCounters = [];
public App()
{
@@ -136,6 +144,12 @@ public partial class App : WinoApplication,
await InitializeServicesAsync();
_synchronizationManager = Services.GetRequiredService<ISynchronizationManager>();
_preferencesService = Services.GetRequiredService<IPreferencesService>();
_accountService = Services.GetRequiredService<IAccountService>();
_preferencesService.PreferenceChanged -= PreferencesServiceChanged;
_preferencesService.PreferenceChanged += PreferencesServiceChanged;
RestartAutoSynchronizationLoop();
// Check if launched from toast notification.
if (IsNotificationActivation(out AppNotificationActivatedEventArgs toastArgs))
@@ -463,6 +477,132 @@ public partial class App : WinoApplication,
};
}
private void PreferencesServiceChanged(object? sender, string propertyName)
{
if (propertyName != nameof(IPreferencesService.EmailSyncIntervalMinutes))
return;
RestartAutoSynchronizationLoop();
}
private void RestartAutoSynchronizationLoop()
{
if (_preferencesService == null)
return;
StopAutoSynchronizationLoop();
int intervalMinutes = Math.Max(1, _preferencesService.EmailSyncIntervalMinutes);
_autoSynchronizationLoopCts = new CancellationTokenSource();
_ = RunAutoSynchronizationLoopAsync(TimeSpan.FromMinutes(intervalMinutes), _autoSynchronizationLoopCts.Token);
LogActivation($"Automatic sync loop started. Interval: {intervalMinutes} minute(s).");
}
private void StopAutoSynchronizationLoop()
{
if (_autoSynchronizationLoopCts == null)
return;
_autoSynchronizationLoopCts.Cancel();
_autoSynchronizationLoopCts.Dispose();
_autoSynchronizationLoopCts = null;
}
private async Task RunAutoSynchronizationLoopAsync(TimeSpan interval, CancellationToken cancellationToken)
{
try
{
await ExecuteAutoSynchronizationAsync(cancellationToken).ConfigureAwait(false);
using var timer = new PeriodicTimer(interval);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
await ExecuteAutoSynchronizationAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// no-op
}
catch (Exception ex)
{
LogActivation($"Automatic sync loop failed: {ex.Message}");
}
}
private async Task ExecuteAutoSynchronizationAsync(CancellationToken cancellationToken)
{
if (_synchronizationManager == null || _accountService == null)
return;
bool lockTaken = false;
try
{
lockTaken = await _autoSynchronizationSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false);
if (!lockTaken)
return;
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var currentAccountIds = accounts.Select(a => a.Id).ToHashSet();
_inboxSyncCounters.Keys.Where(a => !currentAccountIds.Contains(a)).ToList().ForEach(a => _inboxSyncCounters.Remove(a));
foreach (var account in accounts)
{
cancellationToken.ThrowIfCancellationRequested();
if (_synchronizationManager.IsAccountSynchronizing(account.Id))
continue;
var inboxSyncOptions = new MailSynchronizationOptions()
{
AccountId = account.Id,
Type = MailSynchronizationType.InboxOnly
};
var inboxSyncResult = await _synchronizationManager.SynchronizeMailAsync(inboxSyncOptions, cancellationToken).ConfigureAwait(false);
if (inboxSyncResult.CompletedState is SynchronizationCompletedState.Success or SynchronizationCompletedState.PartiallyCompleted)
{
_inboxSyncCounters.TryAdd(account.Id, 0);
_inboxSyncCounters[account.Id]++;
if (_inboxSyncCounters[account.Id] >= InboxSyncsPerFullSync)
{
var fullSyncOptions = new MailSynchronizationOptions()
{
AccountId = account.Id,
Type = MailSynchronizationType.FullFolders
};
await _synchronizationManager.SynchronizeMailAsync(fullSyncOptions, cancellationToken).ConfigureAwait(false);
_inboxSyncCounters[account.Id] = 0;
}
}
if (!account.IsCalendarAccessGranted)
continue;
var calendarOptions = new CalendarSynchronizationOptions()
{
AccountId = account.Id,
Type = CalendarSynchronizationType.CalendarMetadata
};
await _synchronizationManager.SynchronizeCalendarAsync(calendarOptions, cancellationToken).ConfigureAwait(false);
}
}
finally
{
if (lockTaken)
{
_autoSynchronizationSemaphore.Release();
}
}
}
/// <summary>
/// Handles activation redirected from another instance (single-instancing).
/// This is called when a second instance tries to launch and redirects to this existing instance.
@@ -50,12 +50,6 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
set => SetPropertyAndSave(nameof(MailItemDisplayMode), value);
}
public bool IsSemanticZoomEnabled
{
get => _configurationService.Get(nameof(IsSemanticZoomEnabled), true);
set => SetPropertyAndSave(nameof(IsSemanticZoomEnabled), value);
}
public bool IsHardDeleteProtectionEnabled
{
get => _configurationService.Get(nameof(IsHardDeleteProtectionEnabled), true);