From aa16609f8910f8aca431a1db8a6a01d52d56016a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Tue, 14 Apr 2026 01:23:39 +0200 Subject: [PATCH] Add Windows share target draft attachment flow --- .../Interfaces/IShareActivationService.cs | 14 +++ .../Models/Launch/MailShareRequest.cs | 15 +++ .../Launch/PendingComposeMailShareRequest.cs | 15 +++ Wino.Mail.ViewModels/ComposePageViewModel.cs | 25 +++- Wino.Mail.ViewModels/MailAppShellViewModel.cs | 46 ++++++- Wino.Mail.WinUI/App.xaml.cs | 115 ++++++++++++++++++ Wino.Mail.WinUI/Package.appxmanifest | 9 ++ Wino.Services/ServicesContainerSetup.cs | 1 + Wino.Services/ShareActivationService.cs | 70 +++++++++++ 9 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 Wino.Core.Domain/Interfaces/IShareActivationService.cs create mode 100644 Wino.Core.Domain/Models/Launch/MailShareRequest.cs create mode 100644 Wino.Core.Domain/Models/Launch/PendingComposeMailShareRequest.cs create mode 100644 Wino.Services/ShareActivationService.cs diff --git a/Wino.Core.Domain/Interfaces/IShareActivationService.cs b/Wino.Core.Domain/Interfaces/IShareActivationService.cs new file mode 100644 index 00000000..7c340537 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IShareActivationService.cs @@ -0,0 +1,14 @@ +#nullable enable +using System; +using Wino.Core.Domain.Models.Launch; + +namespace Wino.Core.Domain.Interfaces; + +public interface IShareActivationService +{ + MailShareRequest? PendingShareRequest { get; set; } + MailShareRequest? ConsumePendingShareRequest(); + void ClearPendingShareRequest(); + void StagePendingComposeShareRequest(Guid draftUniqueId, MailShareRequest shareRequest); + MailShareRequest? ConsumePendingComposeShareRequest(Guid draftUniqueId); +} diff --git a/Wino.Core.Domain/Models/Launch/MailShareRequest.cs b/Wino.Core.Domain/Models/Launch/MailShareRequest.cs new file mode 100644 index 00000000..c96a745e --- /dev/null +++ b/Wino.Core.Domain/Models/Launch/MailShareRequest.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using Wino.Core.Domain.Models.Common; + +namespace Wino.Core.Domain.Models.Launch; + +public sealed class MailShareRequest +{ + public MailShareRequest(IReadOnlyList files) + { + Files = files ?? throw new ArgumentNullException(nameof(files)); + } + + public IReadOnlyList Files { get; } +} diff --git a/Wino.Core.Domain/Models/Launch/PendingComposeMailShareRequest.cs b/Wino.Core.Domain/Models/Launch/PendingComposeMailShareRequest.cs new file mode 100644 index 00000000..0ef792e1 --- /dev/null +++ b/Wino.Core.Domain/Models/Launch/PendingComposeMailShareRequest.cs @@ -0,0 +1,15 @@ +using System; + +namespace Wino.Core.Domain.Models.Launch; + +public sealed class PendingComposeMailShareRequest +{ + public PendingComposeMailShareRequest(Guid draftUniqueId, MailShareRequest shareRequest) + { + DraftUniqueId = draftUniqueId; + ShareRequest = shareRequest ?? throw new ArgumentNullException(nameof(shareRequest)); + } + + public Guid DraftUniqueId { get; } + public MailShareRequest ShareRequest { get; } +} diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index a1760045..0ee9033a 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -19,6 +19,7 @@ using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models; +using Wino.Core.Domain.Models.Launch; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Extensions; using Wino.Core.Services; @@ -159,6 +160,7 @@ public partial class ComposePageViewModel : MailBaseViewModel, public readonly IPreferencesService PreferencesService; public readonly IContactService ContactService; public readonly ISmimeCertificateService _smimeCertificateService; + private readonly IShareActivationService _shareActivationService; public ComposePageViewModel(IMailDialogService dialogService, IMailService mailService, @@ -172,7 +174,8 @@ public partial class ComposePageViewModel : MailBaseViewModel, IContactService contactService, IFontService fontService, IPreferencesService preferencesService, - ISmimeCertificateService smimeCertificateService) + ISmimeCertificateService smimeCertificateService, + IShareActivationService shareActivationService) { NativeAppService = nativeAppService; ContactService = contactService; @@ -188,6 +191,7 @@ public partial class ComposePageViewModel : MailBaseViewModel, _emailTemplateService = emailTemplateService; _worker = worker; _smimeCertificateService = smimeCertificateService; + _shareActivationService = shareActivationService; foreach (var cert in _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias?.AliasAddress)) { @@ -752,6 +756,7 @@ public partial class ComposePageViewModel : MailBaseViewModel, await LoadAddressInfoAsync(replyingMime.Bcc, BCCItems); LoadAttachments(); + ApplyPendingSharedAttachments(); if (replyingMime.Cc.Any() || replyingMime.Bcc.Any()) IsCCBCCVisible = true; @@ -783,6 +788,24 @@ public partial class ComposePageViewModel : MailBaseViewModel, } } + private void ApplyPendingSharedAttachments() + { + var draftUniqueId = CurrentMailDraftItem?.MailCopy?.UniqueId ?? Guid.Empty; + + if (draftUniqueId == Guid.Empty) + return; + + var shareRequest = _shareActivationService.ConsumePendingComposeShareRequest(draftUniqueId); + + if (shareRequest?.Files == null || shareRequest.Files.Count == 0) + return; + + foreach (var sharedFile in shareRequest.Files) + { + IncludedAttachments.Add(new MailAttachmentViewModel(sharedFile)); + } + } + private async Task LoadAddressInfoAsync(InternetAddressList list, ObservableCollection collection) { foreach (var item in list) diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs index 052b4f36..1fa4033e 100644 --- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs +++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs @@ -15,8 +15,9 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.MenuItems; -using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models; +using Wino.Core.Domain.Models.Folders; +using Wino.Core.Domain.Models.Launch; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; @@ -84,6 +85,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, private readonly IMimeFileService _mimeFileService; private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService; private readonly IStoreUpdateService _storeUpdateService; + private readonly IShareActivationService _shareActivationService; private readonly INativeAppService _nativeAppService; private readonly IMailService _mailService; @@ -109,7 +111,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel, IConfigurationService configurationService, IStartupBehaviorService startupBehaviorService, IWebView2RuntimeValidatorService webView2RuntimeValidatorService, - IStoreUpdateService storeUpdateService) + IStoreUpdateService storeUpdateService, + IShareActivationService shareActivationService) { StatePersistenceService = statePersistanceService; @@ -131,6 +134,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, _winoRequestDelegator = winoRequestDelegator; _webView2RuntimeValidatorService = webView2RuntimeValidatorService; _storeUpdateService = storeUpdateService; + _shareActivationService = shareActivationService; } protected override void OnDispatcherAssigned() @@ -274,6 +278,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } await ProcessLaunchOptionsAsync(); + await HandlePendingShareRequestAsync(); await ValidateWebView2RuntimeAsync(); if (shouldRunStartupFlows && !Debugger.IsAttached) @@ -943,6 +948,9 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } public async Task CreateNewMailForAsync(MailAccount account) + => await CreateNewMailForAsync(account, null); + + public async Task CreateNewMailForAsync(MailAccount account, MailShareRequest shareRequest) { if (account == null) return; @@ -974,6 +982,11 @@ public partial class MailAppShellViewModel : MailBaseViewModel, var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(account.Id, draftOptions).ConfigureAwait(false); + if (shareRequest?.Files?.Count > 0) + { + _shareActivationService.StagePendingComposeShareRequest(draftMailCopy.UniqueId, shareRequest); + } + var draftPreparationRequest = new DraftPreparationRequest(account, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason); await _winoRequestDelegator.ExecuteAsync(draftPreparationRequest); } @@ -1034,6 +1047,35 @@ public partial class MailAppShellViewModel : MailBaseViewModel, await CreateNewMailForAsync(targetAccount); } + public async Task HandlePendingShareRequestAsync() + { + var shareRequest = _shareActivationService.ConsumePendingShareRequest(); + + if (shareRequest?.Files == null || shareRequest.Files.Count == 0) + return; + + var accounts = await _accountService.GetAccountsAsync(); + + if (!accounts.Any()) + return; + + MailAccount targetAccount = null; + + if (accounts.Count == 1) + { + targetAccount = accounts[0]; + } + else + { + targetAccount = await _dialogService.ShowAccountPickerDialogAsync(accounts); + } + + if (targetAccount == null) + return; + + await CreateNewMailForAsync(targetAccount, shareRequest); + } + private async Task RecreateMenuItemsAsync() { await _menuRefreshSemaphore.WaitAsync().ConfigureAwait(false); diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 30bee35d..4df1e294 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -15,6 +15,9 @@ using Microsoft.Windows.AppLifecycle; using Microsoft.Windows.AppNotifications; using MimeKit.Cryptography; using Windows.ApplicationModel.Activation; +using Windows.ApplicationModel.DataTransfer; +using Windows.ApplicationModel.DataTransfer.ShareTarget; +using Windows.Storage; using Wino.Calendar.ViewModels; using Wino.Calendar.ViewModels.Interfaces; using Wino.Core; @@ -22,6 +25,8 @@ using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; +using Wino.Core.Domain.Models.Common; +using Wino.Core.Domain.Models.Launch; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; @@ -30,6 +35,7 @@ using Wino.Mail.Services; using Wino.Mail.ViewModels; using Wino.Mail.ViewModels.Data; using Wino.Mail.WinUI.Activation; +using Wino.Mail.WinUI.Extensions; using Wino.Mail.WinUI.Interfaces; using Wino.Mail.WinUI.Models; using Wino.Mail.WinUI.Services; @@ -61,6 +67,7 @@ public partial class App : WinoApplication, private bool _isExiting; private bool _activationInfrastructureInitialized; private int _initialNotificationActivationHandled; + private int _initialShareActivationHandled; private CancellationTokenSource? _autoSynchronizationLoopCts; private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1); private readonly SemaphoreSlim _activationInfrastructureSemaphore = new(1, 1); @@ -446,12 +453,26 @@ public partial class App : WinoApplication, private bool TryMarkInitialNotificationActivationHandled() => Interlocked.Exchange(ref _initialNotificationActivationHandled, 1) == 0; + private bool TryMarkInitialShareActivationHandled() + => Interlocked.Exchange(ref _initialShareActivationHandled, 1) == 0; + protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) { base.OnLaunched(args); await EnsureActivationInfrastructureAsync(); + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); + + if (activationArgs.Kind == ExtendedActivationKind.ShareTarget && + TryMarkInitialShareActivationHandled()) + { + LogActivation("Processing share target activation from OnLaunched."); + + if (await HandleShareTargetActivationAsync(activationArgs, activateWindow: true)) + return; + } + var hasAnyAccount = _hasConfiguredAccounts; if (!IsStartupTaskLaunch() && !hasAnyAccount) { @@ -635,6 +656,89 @@ public partial class App : WinoApplication, return HandleToastActivationAsync(toastArguments, userInput); } + private async Task HandleShareTargetActivationAsync(AppActivationArguments activationArgs, bool activateWindow) + { + if (activationArgs.Kind != ExtendedActivationKind.ShareTarget || + activationArgs.Data is not ShareTargetActivatedEventArgs shareTargetArgs) + { + return false; + } + + var shareRequest = await ExtractMailShareRequestAsync(shareTargetArgs); + + if (shareRequest?.Files == null || shareRequest.Files.Count == 0) + { + Services.GetRequiredService().ClearPendingShareRequest(); + return false; + } + + var shareActivationService = Services.GetRequiredService(); + shareActivationService.PendingShareRequest = shareRequest; + + if (!_hasConfiguredAccounts) + { + shareActivationService.ClearPendingShareRequest(); + return false; + } + + var shellWindowAlreadyExists = HasShellWindow(); + + await EnsureShellWindowAsync(WinoApplicationMode.Mail, activateWindow, suppressStartupFlows: true); + + if (shellWindowAlreadyExists) + { + await Services.GetRequiredService().HandlePendingShareRequestAsync(); + } + + return true; + } + + private async Task ExtractMailShareRequestAsync(ShareTargetActivatedEventArgs shareTargetArgs) + { + var shareOperation = shareTargetArgs.ShareOperation; + + try + { + shareOperation.ReportStarted(); + + if (!shareOperation.Data.Contains(StandardDataFormats.StorageItems)) + { + shareOperation.ReportCompleted(); + return null; + } + + var storageItems = await shareOperation.Data.GetStorageItemsAsync(); + List sharedFiles = []; + + foreach (var storageFile in storageItems.OfType()) + { + sharedFiles.Add(await storageFile.ToSharedFileAsync()); + } + + shareOperation.ReportDataRetrieved(); + shareOperation.ReportCompleted(); + + return sharedFiles.Count == 0 + ? null + : new MailShareRequest(sharedFiles); + } + catch (Exception ex) + { + LogActivation($"Failed to extract share target payload: {ex.GetType().Name} - {ex.Message}"); + + try + { + shareOperation.ReportError(ex.Message); + } + catch + { + // Ignore share reporting failures and fall back to normal launch flow. + } + + return null; + } + } + private async Task EnsureShellWindowAsync(WinoApplicationMode mode, bool activateWindow, bool suppressStartupFlows = true) { var windowManager = Services.GetRequiredService(); @@ -1446,6 +1550,11 @@ public partial class App : WinoApplication, LogActivation($"Processing redirected notification activation. Arguments: {toastArgs.Argument}"); _ = HandleToastActivationAsync(toastArgs.Argument, toastArgs.UserInput); } + else if (args.Kind == ExtendedActivationKind.ShareTarget) + { + LogActivation("Processing redirected share target activation."); + await HandleShareTargetActivationAsync(args, activateWindow: true); + } else { var shouldActivateWindow = true; @@ -1527,6 +1636,12 @@ public partial class App : WinoApplication, } + if (activationArgs.Kind == ExtendedActivationKind.ShareTarget) + { + mode = WinoApplicationMode.Mail; + return true; + } + if (activationArgs.Kind == ExtendedActivationKind.File && activationArgs.Data is IFileActivatedEventArgs fileArgs) { diff --git a/Wino.Mail.WinUI/Package.appxmanifest b/Wino.Mail.WinUI/Package.appxmanifest index af6072a8..d11a2dda 100644 --- a/Wino.Mail.WinUI/Package.appxmanifest +++ b/Wino.Mail.WinUI/Package.appxmanifest @@ -97,6 +97,15 @@ + + + + + + + + + diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index aad6fa4e..063a7409 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -13,6 +13,7 @@ public static class ServicesContainerSetup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddTransient(); diff --git a/Wino.Services/ShareActivationService.cs b/Wino.Services/ShareActivationService.cs new file mode 100644 index 00000000..8658e418 --- /dev/null +++ b/Wino.Services/ShareActivationService.cs @@ -0,0 +1,70 @@ +#nullable enable +using System; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Launch; + +namespace Wino.Services; + +public class ShareActivationService : IShareActivationService +{ + private readonly object _syncRoot = new(); + private MailShareRequest? _pendingShareRequest; + private PendingComposeMailShareRequest? _pendingComposeShareRequest; + + public MailShareRequest? PendingShareRequest + { + get + { + lock (_syncRoot) + { + return _pendingShareRequest; + } + } + set + { + lock (_syncRoot) + { + _pendingShareRequest = value; + } + } + } + + public MailShareRequest? ConsumePendingShareRequest() + { + lock (_syncRoot) + { + var pendingRequest = _pendingShareRequest; + _pendingShareRequest = null; + return pendingRequest; + } + } + + public void ClearPendingShareRequest() + { + lock (_syncRoot) + { + _pendingShareRequest = null; + } + } + + public void StagePendingComposeShareRequest(Guid draftUniqueId, MailShareRequest shareRequest) + { + lock (_syncRoot) + { + _pendingComposeShareRequest = new PendingComposeMailShareRequest(draftUniqueId, shareRequest); + } + } + + public MailShareRequest? ConsumePendingComposeShareRequest(Guid draftUniqueId) + { + lock (_syncRoot) + { + if (_pendingComposeShareRequest?.DraftUniqueId != draftUniqueId) + return null; + + var pendingRequest = _pendingComposeShareRequest.ShareRequest; + _pendingComposeShareRequest = null; + return pendingRequest; + } + } +}