Deep link on purchase success.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using Wino.Core.Domain.Models.Launch;
|
using System;
|
||||||
|
using Wino.Core.Domain.Models.Launch;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Interfaces;
|
namespace Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
@@ -13,4 +14,5 @@ public interface ILaunchProtocolService
|
|||||||
/// Used to handle mailto links.
|
/// Used to handle mailto links.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
MailToUri MailToUri { get; set; }
|
MailToUri MailToUri { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
@@ -16,6 +17,7 @@ public interface IWinoAccountProfileService
|
|||||||
Task<WinoAccountOperationResult> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
|
Task<WinoAccountOperationResult> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
|
||||||
Task<WinoAccountOperationResult> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
|
Task<WinoAccountOperationResult> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
|
||||||
Task<WinoAccountOperationResult> RefreshAsync(CancellationToken cancellationToken = default);
|
Task<WinoAccountOperationResult> RefreshAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<WinoAccountOperationResult> RefreshProfileAsync(CancellationToken cancellationToken = default);
|
||||||
Task<WinoAccount?> GetActiveAccountAsync();
|
Task<WinoAccount?> GetActiveAccountAsync();
|
||||||
Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
|
Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
|
||||||
Task<bool> HasActiveAccountAsync();
|
Task<bool> HasActiveAccountAsync();
|
||||||
@@ -24,5 +26,6 @@ public interface IWinoAccountProfileService
|
|||||||
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
|
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
|
||||||
Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
|
Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
|
||||||
Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
|
Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default);
|
||||||
Task SignOutAsync(CancellationToken cancellationToken = default);
|
Task SignOutAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,68 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
|
|||||||
result.ErrorMessage.Should().Be("Password does not match this account.");
|
result.ErrorMessage.Should().Be("Password does not match this account.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshProfileAsync_ShouldPersistLatestProfileData()
|
||||||
|
{
|
||||||
|
var authResult = CreateAuthResult("first@example.com");
|
||||||
|
|
||||||
|
_apiClient
|
||||||
|
.Setup(x => x.LoginAsync("first@example.com", "pw", default))
|
||||||
|
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(authResult));
|
||||||
|
|
||||||
|
_apiClient
|
||||||
|
.Setup(x => x.GetCurrentUserAsync(default))
|
||||||
|
.ReturnsAsync(ApiEnvelope<AuthUserDto>.Success(new AuthUserDto(
|
||||||
|
authResult.User.UserId,
|
||||||
|
"updated@example.com",
|
||||||
|
"Premium",
|
||||||
|
authResult.User.HasPassword,
|
||||||
|
authResult.User.HasGoogleLogin,
|
||||||
|
authResult.User.HasFacebookLogin)));
|
||||||
|
|
||||||
|
await _service.LoginAsync("first@example.com", "pw");
|
||||||
|
|
||||||
|
var result = await _service.RefreshProfileAsync();
|
||||||
|
|
||||||
|
result.IsSuccess.Should().BeTrue();
|
||||||
|
result.Account.Should().NotBeNull();
|
||||||
|
result.Account!.Email.Should().Be("updated@example.com");
|
||||||
|
result.Account.AccountStatus.Should().Be("Premium");
|
||||||
|
|
||||||
|
var persisted = await _databaseService.Connection.Table<WinoAccount>().FirstOrDefaultAsync();
|
||||||
|
persisted.Should().NotBeNull();
|
||||||
|
persisted!.Email.Should().Be("updated@example.com");
|
||||||
|
persisted.AccountStatus.Should().Be("Premium");
|
||||||
|
persisted.AccessToken.Should().Be(authResult.AccessToken);
|
||||||
|
persisted.RefreshToken.Should().Be(authResult.RefreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessBillingCallbackAsync_ShouldConfirmPurchasedAddOn()
|
||||||
|
{
|
||||||
|
var authResult = CreateAuthResult("first@example.com");
|
||||||
|
var callbackUri = new Uri("wino://billing/success?productCode=UNLIMITED_ACCOUNTS");
|
||||||
|
|
||||||
|
_apiClient
|
||||||
|
.Setup(x => x.LoginAsync("first@example.com", "pw", default))
|
||||||
|
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(authResult));
|
||||||
|
|
||||||
|
_apiClient
|
||||||
|
.Setup(x => x.GetCurrentUserAsync(default))
|
||||||
|
.ReturnsAsync(ApiEnvelope<AuthUserDto>.Success(authResult.User));
|
||||||
|
|
||||||
|
_storeManagementService
|
||||||
|
.Setup(x => x.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
await _service.LoginAsync("first@example.com", "pw");
|
||||||
|
|
||||||
|
var processed = await _service.ProcessBillingCallbackAsync(callbackUri);
|
||||||
|
|
||||||
|
processed.Should().BeTrue();
|
||||||
|
_apiClient.Verify(x => x.GetCurrentUserAsync(default), Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
|
||||||
private static AuthResultDto CreateAuthResult(string email)
|
private static AuthResultDto CreateAuthResult(string email)
|
||||||
{
|
{
|
||||||
return new AuthResultDto(
|
return new AuthResultDto(
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ using Wino.Messaging.UI;
|
|||||||
namespace Wino.Core.ViewModels;
|
namespace Wino.Core.ViewModels;
|
||||||
|
|
||||||
public partial class SettingOptionsPageViewModel : CoreBaseViewModel,
|
public partial class SettingOptionsPageViewModel : CoreBaseViewModel,
|
||||||
IRecipient<WinoAccountSignedInMessage>,
|
IRecipient<WinoAccountProfileUpdatedMessage>,
|
||||||
IRecipient<WinoAccountSignedOutMessage>
|
IRecipient<WinoAccountProfileDeletedMessage>,
|
||||||
|
IRecipient<WinoAccountAddOnPurchasedMessage>
|
||||||
{
|
{
|
||||||
private const string BuyAiPackUrl = "https://example.com/wino-ai-pack";
|
private const string BuyAiPackUrl = "https://example.com/wino-ai-pack";
|
||||||
|
|
||||||
@@ -420,24 +421,29 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel,
|
|||||||
{
|
{
|
||||||
base.RegisterRecipients();
|
base.RegisterRecipients();
|
||||||
|
|
||||||
Messenger.Register<WinoAccountSignedInMessage>(this);
|
Messenger.Register<WinoAccountProfileUpdatedMessage>(this);
|
||||||
Messenger.Register<WinoAccountSignedOutMessage>(this);
|
Messenger.Register<WinoAccountProfileDeletedMessage>(this);
|
||||||
|
Messenger.Register<WinoAccountAddOnPurchasedMessage>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void UnregisterRecipients()
|
protected override void UnregisterRecipients()
|
||||||
{
|
{
|
||||||
base.UnregisterRecipients();
|
base.UnregisterRecipients();
|
||||||
|
|
||||||
Messenger.Unregister<WinoAccountSignedInMessage>(this);
|
Messenger.Unregister<WinoAccountProfileUpdatedMessage>(this);
|
||||||
Messenger.Unregister<WinoAccountSignedOutMessage>(this);
|
Messenger.Unregister<WinoAccountProfileDeletedMessage>(this);
|
||||||
|
Messenger.Unregister<WinoAccountAddOnPurchasedMessage>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(WinoAccountSignedInMessage message)
|
public void Receive(WinoAccountProfileUpdatedMessage message)
|
||||||
=> _ = LoadWinoAccountAsync();
|
=> _ = LoadWinoAccountAsync();
|
||||||
|
|
||||||
public void Receive(WinoAccountSignedOutMessage message)
|
public void Receive(WinoAccountProfileDeletedMessage message)
|
||||||
=> _ = ResetWinoAccountStateAsync();
|
=> _ = ResetWinoAccountStateAsync();
|
||||||
|
|
||||||
|
public void Receive(WinoAccountAddOnPurchasedMessage message)
|
||||||
|
=> _ = LoadWinoAccountAsync();
|
||||||
|
|
||||||
// Wino Account hero card commands and helpers
|
// Wino Account hero card commands and helpers
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ using Wino.Core.Domain.Enums;
|
|||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Accounts;
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
using Wino.Mail.Api.Contracts.Billing;
|
|
||||||
using Wino.Core.ViewModels.Data;
|
using Wino.Core.ViewModels.Data;
|
||||||
using Wino.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
|
|
||||||
namespace Wino.Core.ViewModels;
|
namespace Wino.Core.ViewModels;
|
||||||
|
|
||||||
public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||||
IRecipient<WinoAccountSignedInMessage>,
|
IRecipient<WinoAccountProfileUpdatedMessage>,
|
||||||
IRecipient<WinoAccountSignedOutMessage>
|
IRecipient<WinoAccountProfileDeletedMessage>,
|
||||||
|
IRecipient<WinoAccountAddOnPurchasedMessage>
|
||||||
{
|
{
|
||||||
private readonly IWinoAccountProfileService _profileService;
|
private readonly IWinoAccountProfileService _profileService;
|
||||||
private readonly IWinoAddOnService _addOnService;
|
private readonly IWinoAddOnService _addOnService;
|
||||||
@@ -62,7 +62,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
|||||||
public override void OnNavigatedTo(NavigationMode mode, object parameters)
|
public override void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||||
{
|
{
|
||||||
base.OnNavigatedTo(mode, parameters);
|
base.OnNavigatedTo(mode, parameters);
|
||||||
_ = LoadAsync();
|
_ = InitializeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -220,24 +220,34 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
|||||||
{
|
{
|
||||||
base.RegisterRecipients();
|
base.RegisterRecipients();
|
||||||
|
|
||||||
Messenger.Register<WinoAccountSignedInMessage>(this);
|
Messenger.Register<WinoAccountProfileUpdatedMessage>(this);
|
||||||
Messenger.Register<WinoAccountSignedOutMessage>(this);
|
Messenger.Register<WinoAccountProfileDeletedMessage>(this);
|
||||||
|
Messenger.Register<WinoAccountAddOnPurchasedMessage>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void UnregisterRecipients()
|
protected override void UnregisterRecipients()
|
||||||
{
|
{
|
||||||
base.UnregisterRecipients();
|
base.UnregisterRecipients();
|
||||||
|
|
||||||
Messenger.Unregister<WinoAccountSignedInMessage>(this);
|
Messenger.Unregister<WinoAccountProfileUpdatedMessage>(this);
|
||||||
Messenger.Unregister<WinoAccountSignedOutMessage>(this);
|
Messenger.Unregister<WinoAccountProfileDeletedMessage>(this);
|
||||||
|
Messenger.Unregister<WinoAccountAddOnPurchasedMessage>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(WinoAccountSignedInMessage message)
|
public void Receive(WinoAccountProfileUpdatedMessage message)
|
||||||
=> _ = LoadAsync();
|
=> _ = LoadAsync();
|
||||||
|
|
||||||
public void Receive(WinoAccountSignedOutMessage message)
|
public void Receive(WinoAccountProfileDeletedMessage message)
|
||||||
=> _ = LoadAsync();
|
=> _ = LoadAsync();
|
||||||
|
|
||||||
|
public void Receive(WinoAccountAddOnPurchasedMessage message)
|
||||||
|
=> _ = HandleAddOnPurchasedAsync();
|
||||||
|
|
||||||
|
private async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() => IsBusy = true);
|
await ExecuteUIThread(() => IsBusy = true);
|
||||||
@@ -271,6 +281,15 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleAddOnPurchasedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
_dialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle,
|
||||||
|
Translator.Info_PurchaseThankYouMessage,
|
||||||
|
InfoBarMessageType.Success);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ResetStateAsync()
|
private async Task ResetStateAsync()
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ public partial class App : WinoApplication,
|
|||||||
{
|
{
|
||||||
private const int InboxSyncsPerFullSync = 20;
|
private const int InboxSyncsPerFullSync = 20;
|
||||||
private const string ToggleDefaultModeLaunchArgument = "--mode=toggle-default";
|
private const string ToggleDefaultModeLaunchArgument = "--mode=toggle-default";
|
||||||
|
private const string WinoProtocolScheme = "wino";
|
||||||
|
private const string BillingProtocolHost = "billing";
|
||||||
|
private const string BillingSuccessPath = "/success";
|
||||||
private ISynchronizationManager? _synchronizationManager;
|
private ISynchronizationManager? _synchronizationManager;
|
||||||
private IPreferencesService? _preferencesService;
|
private IPreferencesService? _preferencesService;
|
||||||
private IAccountService? _accountService;
|
private IAccountService? _accountService;
|
||||||
@@ -257,7 +260,11 @@ public partial class App : WinoApplication,
|
|||||||
// Initialize theme service after window creation.
|
// Initialize theme service after window creation.
|
||||||
// Theme service requires the window to exist to properly load and apply themes.
|
// Theme service requires the window to exist to properly load and apply themes.
|
||||||
await NewThemeService.InitializeAsync();
|
await NewThemeService.InitializeAsync();
|
||||||
|
|
||||||
|
// Wino account loading and activation.
|
||||||
await LoadInitialWinoAccountAsync();
|
await LoadInitialWinoAccountAsync();
|
||||||
|
await HandlePostActivationAsync(AppInstance.GetCurrent().GetActivatedEventArgs());
|
||||||
|
|
||||||
LogActivation("Theme service initialized.");
|
LogActivation("Theme service initialized.");
|
||||||
|
|
||||||
// If startup task launch, keep window hidden (system tray only).
|
// If startup task launch, keep window hidden (system tray only).
|
||||||
@@ -836,7 +843,7 @@ public partial class App : WinoApplication,
|
|||||||
|
|
||||||
if (winoAccount != null)
|
if (winoAccount != null)
|
||||||
{
|
{
|
||||||
WeakReferenceMessenger.Default.Send(new WinoAccountSignedInMessage(winoAccount));
|
WeakReferenceMessenger.Default.Send(new WinoAccountProfileUpdatedMessage(winoAccount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,7 +948,7 @@ public partial class App : WinoApplication,
|
|||||||
public void HandleRedirectedActivation(AppActivationArguments args)
|
public void HandleRedirectedActivation(AppActivationArguments args)
|
||||||
{
|
{
|
||||||
// Dispatch to UI thread since this is called from Program.OnActivated
|
// Dispatch to UI thread since this is called from Program.OnActivated
|
||||||
MainWindow?.DispatcherQueue.TryEnqueue(() =>
|
MainWindow?.DispatcherQueue.TryEnqueue(async () =>
|
||||||
{
|
{
|
||||||
// Handle different activation kinds
|
// Handle different activation kinds
|
||||||
if (args.Kind == ExtendedActivationKind.AppNotification)
|
if (args.Kind == ExtendedActivationKind.AppNotification)
|
||||||
@@ -972,6 +979,8 @@ public partial class App : WinoApplication,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await HandlePostActivationAsync(args);
|
||||||
|
|
||||||
// Bring the existing window to front after handling redirected activation.
|
// Bring the existing window to front after handling redirected activation.
|
||||||
MainWindow?.BringToFront();
|
MainWindow?.BringToFront();
|
||||||
MainWindow?.Activate();
|
MainWindow?.Activate();
|
||||||
@@ -1017,6 +1026,13 @@ public partial class App : WinoApplication,
|
|||||||
mode = WinoApplicationMode.Mail;
|
mode = WinoApplicationMode.Mail;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(scheme, WinoProtocolScheme, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(protocolArgs.Uri?.Host, BillingProtocolHost, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
mode = WinoApplicationMode.Settings;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activationArgs.Kind == ExtendedActivationKind.File &&
|
if (activationArgs.Kind == ExtendedActivationKind.File &&
|
||||||
@@ -1060,6 +1076,55 @@ public partial class App : WinoApplication,
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandlePostActivationAsync(AppActivationArguments activationArgs)
|
||||||
|
{
|
||||||
|
if (await TryHandleBillingProtocolActivationAsync(activationArgs).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryHandleBillingProtocolActivationAsync(AppActivationArguments activationArgs)
|
||||||
|
{
|
||||||
|
if (!TryGetBillingCallbackUri(activationArgs, out var callbackUri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Services.GetRequiredService<INavigationService>().Navigate(
|
||||||
|
WinoPage.SettingsPage,
|
||||||
|
WinoPage.WinoAccountManagementPage,
|
||||||
|
NavigationReferenceFrame.ShellFrame,
|
||||||
|
NavigationTransitionType.None);
|
||||||
|
|
||||||
|
var winoAccountProfileService = Services.GetRequiredService<IWinoAccountProfileService>();
|
||||||
|
await winoAccountProfileService.ProcessBillingCallbackAsync(callbackUri).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetBillingCallbackUri(AppActivationArguments activationArgs, out Uri callbackUri)
|
||||||
|
{
|
||||||
|
callbackUri = null!;
|
||||||
|
|
||||||
|
if (activationArgs.Kind != ExtendedActivationKind.Protocol ||
|
||||||
|
activationArgs.Data is not IProtocolActivatedEventArgs protocolArgs ||
|
||||||
|
protocolArgs.Uri == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var uri = protocolArgs.Uri;
|
||||||
|
if (!string.Equals(uri.Scheme, WinoProtocolScheme, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!string.Equals(uri.Host, BillingProtocolHost, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!string.Equals(uri.AbsolutePath, BillingSuccessPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackUri = uri;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,13 @@
|
|||||||
</uap:Protocol>
|
</uap:Protocol>
|
||||||
</uap:Extension>
|
</uap:Extension>
|
||||||
|
|
||||||
|
<!-- Protocol activation: Wino deep links -->
|
||||||
|
<uap:Extension Category="windows.protocol">
|
||||||
|
<uap:Protocol Name="wino">
|
||||||
|
<uap:DisplayName>Wino Mail Protocol</uap:DisplayName>
|
||||||
|
</uap:Protocol>
|
||||||
|
</uap:Extension>
|
||||||
|
|
||||||
<!-- Protocol activation: webcal -->
|
<!-- Protocol activation: webcal -->
|
||||||
<uap:Extension Category="windows.protocol">
|
<uap:Extension Category="windows.protocol">
|
||||||
<uap:Protocol Name="webcal">
|
<uap:Protocol Name="webcal">
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
|
|||||||
IRecipient<TitleBarShellContentUpdated>,
|
IRecipient<TitleBarShellContentUpdated>,
|
||||||
IRecipient<SynchronizationActionsAdded>,
|
IRecipient<SynchronizationActionsAdded>,
|
||||||
IRecipient<SynchronizationActionsCompleted>,
|
IRecipient<SynchronizationActionsCompleted>,
|
||||||
IRecipient<WinoAccountSignedInMessage>,
|
IRecipient<WinoAccountProfileUpdatedMessage>,
|
||||||
IRecipient<WinoAccountSignedOutMessage>
|
IRecipient<WinoAccountProfileDeletedMessage>
|
||||||
{
|
{
|
||||||
public IStatePersistanceService StatePersistanceService { get; } = WinoApplication.Current.Services.GetService<IStatePersistanceService>() ?? throw new Exception("StatePersistanceService not registered in DI container.");
|
public IStatePersistanceService StatePersistanceService { get; } = WinoApplication.Current.Services.GetService<IStatePersistanceService>() ?? throw new Exception("StatePersistanceService not registered in DI container.");
|
||||||
public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService<IPreferencesService>() ?? throw new Exception("PreferencesService not registered in DI container.");
|
public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService<IPreferencesService>() ?? throw new Exception("PreferencesService not registered in DI container.");
|
||||||
@@ -213,12 +213,12 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(WinoAccountSignedInMessage message)
|
public void Receive(WinoAccountProfileUpdatedMessage message)
|
||||||
{
|
{
|
||||||
DispatcherQueue.TryEnqueue(() => UpdateWinoAccountState(message.Account));
|
DispatcherQueue.TryEnqueue(() => UpdateWinoAccountState(message.Account));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(WinoAccountSignedOutMessage message)
|
public void Receive(WinoAccountProfileDeletedMessage message)
|
||||||
{
|
{
|
||||||
DispatcherQueue.TryEnqueue(() => UpdateWinoAccountState(null));
|
DispatcherQueue.TryEnqueue(() => UpdateWinoAccountState(null));
|
||||||
}
|
}
|
||||||
@@ -326,8 +326,8 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
|
|||||||
WeakReferenceMessenger.Default.Register<InfoBarMessageRequested>(this);
|
WeakReferenceMessenger.Default.Register<InfoBarMessageRequested>(this);
|
||||||
WeakReferenceMessenger.Default.Register<SynchronizationActionsAdded>(this);
|
WeakReferenceMessenger.Default.Register<SynchronizationActionsAdded>(this);
|
||||||
WeakReferenceMessenger.Default.Register<SynchronizationActionsCompleted>(this);
|
WeakReferenceMessenger.Default.Register<SynchronizationActionsCompleted>(this);
|
||||||
WeakReferenceMessenger.Default.Register<WinoAccountSignedInMessage>(this);
|
WeakReferenceMessenger.Default.Register<WinoAccountProfileUpdatedMessage>(this);
|
||||||
WeakReferenceMessenger.Default.Register<WinoAccountSignedOutMessage>(this);
|
WeakReferenceMessenger.Default.Register<WinoAccountProfileDeletedMessage>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UnregisterRecipients()
|
private void UnregisterRecipients()
|
||||||
@@ -337,8 +337,8 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
|
|||||||
WeakReferenceMessenger.Default.Unregister<InfoBarMessageRequested>(this);
|
WeakReferenceMessenger.Default.Unregister<InfoBarMessageRequested>(this);
|
||||||
WeakReferenceMessenger.Default.Unregister<SynchronizationActionsAdded>(this);
|
WeakReferenceMessenger.Default.Unregister<SynchronizationActionsAdded>(this);
|
||||||
WeakReferenceMessenger.Default.Unregister<SynchronizationActionsCompleted>(this);
|
WeakReferenceMessenger.Default.Unregister<SynchronizationActionsCompleted>(this);
|
||||||
WeakReferenceMessenger.Default.Unregister<WinoAccountSignedInMessage>(this);
|
WeakReferenceMessenger.Default.Unregister<WinoAccountProfileUpdatedMessage>(this);
|
||||||
WeakReferenceMessenger.Default.Unregister<WinoAccountSignedOutMessage>(this);
|
WeakReferenceMessenger.Default.Unregister<WinoAccountProfileDeletedMessage>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowInfoBarMessage(InfoBarMessageRequested message)
|
private void ShowInfoBarMessage(InfoBarMessageRequested message)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Wino.Messaging.UI;
|
||||||
|
|
||||||
|
public record WinoAccountAddOnPurchasedMessage(WinoAddOnProductType ProductType) : UIMessageBase<WinoAccountAddOnPurchasedMessage>;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
|
namespace Wino.Messaging.UI;
|
||||||
|
|
||||||
|
public record WinoAccountProfileDeletedMessage(WinoAccount Account) : UIMessageBase<WinoAccountProfileDeletedMessage>;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
|
namespace Wino.Messaging.UI;
|
||||||
|
|
||||||
|
public record WinoAccountProfileUpdatedMessage(WinoAccount Account) : UIMessageBase<WinoAccountProfileUpdatedMessage>;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Launch;
|
using Wino.Core.Domain.Models.Launch;
|
||||||
|
|
||||||
namespace Wino.Services;
|
namespace Wino.Services;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public static class ServicesContainerSetup
|
|||||||
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
|
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
|
||||||
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
|
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
|
||||||
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
|
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
|
||||||
services.AddTransient<IWinoAccountProfileService, WinoAccountProfileService>();
|
services.AddSingleton<IWinoAccountProfileService, WinoAccountProfileService>();
|
||||||
services.AddTransient<IWinoAddOnService, WinoAddOnService>();
|
services.AddTransient<IWinoAddOnService, WinoAddOnService>();
|
||||||
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
|
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
{
|
{
|
||||||
private readonly IWinoAccountApiClient _apiClient;
|
private readonly IWinoAccountApiClient _apiClient;
|
||||||
private readonly IStoreManagementService _storeManagementService;
|
private readonly IStoreManagementService _storeManagementService;
|
||||||
|
private readonly SemaphoreSlim _billingCallbackLock = new(1, 1);
|
||||||
private readonly ILogger _logger = Log.ForContext<WinoAccountProfileService>();
|
private readonly ILogger _logger = Log.ForContext<WinoAccountProfileService>();
|
||||||
|
|
||||||
public WinoAccountProfileService(IDatabaseService databaseService,
|
public WinoAccountProfileService(IDatabaseService databaseService,
|
||||||
@@ -37,6 +38,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
|
|
||||||
if (result.IsSuccess && result.Account != null)
|
if (result.IsSuccess && result.Account != null)
|
||||||
{
|
{
|
||||||
|
PublishProfileUpdated(result.Account);
|
||||||
ReportUIChange(new WinoAccountSignedInMessage(result.Account));
|
ReportUIChange(new WinoAccountSignedInMessage(result.Account));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
|
|
||||||
if (result.IsSuccess && result.Account != null)
|
if (result.IsSuccess && result.Account != null)
|
||||||
{
|
{
|
||||||
|
PublishProfileUpdated(result.Account);
|
||||||
ReportUIChange(new WinoAccountSignedInMessage(result.Account));
|
ReportUIChange(new WinoAccountSignedInMessage(result.Account));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +75,44 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
_logger.Warning("Wino account token refresh failed for {Email}. Error code: {ErrorCode}", account.Email, result.ErrorCode);
|
_logger.Warning("Wino account token refresh failed for {Email}. Error code: {ErrorCode}", account.Email, result.ErrorCode);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Account != null && !AreEquivalentProfiles(account, result.Account))
|
||||||
|
{
|
||||||
|
PublishProfileUpdated(result.Account);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WinoAccountOperationResult> RefreshProfileAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (account == null)
|
||||||
|
{
|
||||||
|
return WinoAccountOperationResult.Failure("MissingAccessToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _apiClient.GetCurrentUserAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccess || response.Result == null)
|
||||||
|
{
|
||||||
|
_logger.Warning("Failed to refresh Wino account profile for {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
|
||||||
|
return WinoAccountOperationResult.Failure(response.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshedAccount = MergeAccountProfile(account, response.Result);
|
||||||
|
if (AreEquivalentProfiles(account, refreshedAccount))
|
||||||
|
{
|
||||||
|
return WinoAccountOperationResult.Success(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersistAccountAsync(refreshedAccount).ConfigureAwait(false);
|
||||||
|
PublishProfileUpdated(refreshedAccount);
|
||||||
|
|
||||||
|
return WinoAccountOperationResult.Success(refreshedAccount);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WinoAccount?> GetActiveAccountAsync()
|
public async Task<WinoAccount?> GetActiveAccountAsync()
|
||||||
{
|
{
|
||||||
var account = await Connection.Table<WinoAccount>().FirstOrDefaultAsync().ConfigureAwait(false);
|
var account = await Connection.Table<WinoAccount>().FirstOrDefaultAsync().ConfigureAwait(false);
|
||||||
@@ -193,6 +229,53 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _billingCallbackLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targetProductType = ResolveProductType(callbackUri);
|
||||||
|
if (targetProductType == null)
|
||||||
|
{
|
||||||
|
_logger.Warning("Billing callback was ignored because productCode is missing or unsupported. Uri: {Uri}", callbackUri);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) == null)
|
||||||
|
{
|
||||||
|
_logger.Warning("Billing callback was ignored because there is no authenticated Wino account.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int maxAttempts = 15;
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var refreshResult = await RefreshProfileAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (refreshResult.IsSuccess && await HasAddOnAsync(targetProductType.Value, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
ReportUIChange(new WinoAccountAddOnPurchasedMessage(targetProductType.Value));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts - 1)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_billingCallbackLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SignOutAsync(CancellationToken cancellationToken = default)
|
public async Task SignOutAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
||||||
@@ -217,6 +300,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
|
|
||||||
if (account != null)
|
if (account != null)
|
||||||
{
|
{
|
||||||
|
ReportUIChange(new WinoAccountProfileDeletedMessage(account));
|
||||||
ReportUIChange(new WinoAccountSignedOutMessage(account));
|
ReportUIChange(new WinoAccountSignedOutMessage(account));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,12 +315,20 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
|
|
||||||
var account = Map(response.Result);
|
var account = Map(response.Result);
|
||||||
|
|
||||||
await Connection.DeleteAllAsync<WinoAccount>().ConfigureAwait(false);
|
await PersistAccountAsync(account).ConfigureAwait(false);
|
||||||
await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return WinoAccountOperationResult.Success(account);
|
return WinoAccountOperationResult.Success(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PersistAccountAsync(WinoAccount account)
|
||||||
|
{
|
||||||
|
await Connection.DeleteAllAsync<WinoAccount>().ConfigureAwait(false);
|
||||||
|
await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishProfileUpdated(WinoAccount account)
|
||||||
|
=> ReportUIChange(new WinoAccountProfileUpdatedMessage(account));
|
||||||
|
|
||||||
private async Task<bool> HasAiPackAsync(CancellationToken cancellationToken)
|
private async Task<bool> HasAiPackAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false);
|
var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -264,6 +356,30 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts;
|
return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right)
|
||||||
|
=> left.Id == right.Id &&
|
||||||
|
string.Equals(left.Email, right.Email, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(left.AccountStatus, right.AccountStatus, StringComparison.Ordinal) &&
|
||||||
|
left.HasPassword == right.HasPassword &&
|
||||||
|
left.HasGoogleLogin == right.HasGoogleLogin &&
|
||||||
|
left.HasFacebookLogin == right.HasFacebookLogin;
|
||||||
|
|
||||||
|
private static WinoAccount MergeAccountProfile(WinoAccount existingAccount, AuthUserDto profile)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = profile.UserId,
|
||||||
|
Email = profile.Email,
|
||||||
|
AccountStatus = profile.AccountStatus,
|
||||||
|
HasPassword = profile.HasPassword,
|
||||||
|
HasGoogleLogin = profile.HasGoogleLogin,
|
||||||
|
HasFacebookLogin = profile.HasFacebookLogin,
|
||||||
|
AccessToken = existingAccount.AccessToken,
|
||||||
|
AccessTokenExpiresAtUtc = existingAccount.AccessTokenExpiresAtUtc,
|
||||||
|
RefreshToken = existingAccount.RefreshToken,
|
||||||
|
RefreshTokenExpiresAtUtc = existingAccount.RefreshTokenExpiresAtUtc,
|
||||||
|
LastAuthenticatedUtc = existingAccount.LastAuthenticatedUtc
|
||||||
|
};
|
||||||
|
|
||||||
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflected contract property is a stable API field read from a concrete DTO instance.")]
|
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflected contract property is a stable API field read from a concrete DTO instance.")]
|
||||||
private static bool TryGetBooleanProperty(object instance, string propertyName, out bool value)
|
private static bool TryGetBooleanProperty(object instance, string propertyName, out bool value)
|
||||||
{
|
{
|
||||||
@@ -279,6 +395,44 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WinoAddOnProductType? ResolveProductType(Uri callbackUri)
|
||||||
|
{
|
||||||
|
var productCode = GetQueryParameter(callbackUri, "productCode");
|
||||||
|
if (string.IsNullOrWhiteSpace(productCode))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return productCode.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"AI_PACK" => WinoAddOnProductType.AI_PACK,
|
||||||
|
"UNLIMITED_ACCOUNTS" => WinoAddOnProductType.UNLIMITED_ACCOUNTS,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetQueryParameter(Uri uri, string key)
|
||||||
|
{
|
||||||
|
var query = uri.Query;
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var part in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var pieces = part.Split('=', 2);
|
||||||
|
if (pieces.Length == 0 || !string.Equals(pieces[0], key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pieces.Length > 1 ? Uri.UnescapeDataString(pieces[1]) : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private static WinoAccount Map(AuthResultDto result)
|
private static WinoAccount Map(AuthResultDto result)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user