diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index d5e58b8f5f..5e40c0566b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -3273,6 +3273,12 @@ EEDE50122BA360C80017F3C4 /* NetworkProtection+VPNAgentConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEDE50102BA360C80017F3C4 /* NetworkProtection+VPNAgentConvenienceInitializers.swift */; }; EEF12E6F2A2111880023E6BF /* MacPacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF12E6D2A2111880023E6BF /* MacPacketTunnelProvider.swift */; }; EEF53E182950CED5002D78F4 /* JSAlertViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */; }; + F1B33DF22BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B33DF12BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift */; }; + F1B33DF32BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B33DF12BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift */; }; + F1B33DF42BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B33DF12BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift */; }; + F1B33DF62BAD970E001128B3 /* SubscriptionErrorReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B33DF52BAD970E001128B3 /* SubscriptionErrorReporter.swift */; }; + F1B33DF72BAD970E001128B3 /* SubscriptionErrorReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B33DF52BAD970E001128B3 /* SubscriptionErrorReporter.swift */; }; + F1B33DF82BAD970E001128B3 /* SubscriptionErrorReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B33DF52BAD970E001128B3 /* SubscriptionErrorReporter.swift */; }; F1D43AEE2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D43AED2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift */; }; F1D43AEF2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D43AED2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift */; }; F1D43AF02B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D43AED2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift */; }; @@ -4666,6 +4672,8 @@ EEDE50102BA360C80017F3C4 /* NetworkProtection+VPNAgentConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkProtection+VPNAgentConvenienceInitializers.swift"; sourceTree = ""; }; EEF12E6D2A2111880023E6BF /* MacPacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPacketTunnelProvider.swift; sourceTree = ""; }; EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSAlertViewModelTests.swift; sourceTree = ""; }; + F1B33DF12BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAppStoreRestorer.swift; sourceTree = ""; }; + F1B33DF52BAD970E001128B3 /* SubscriptionErrorReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionErrorReporter.swift; sourceTree = ""; }; F1D43AED2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MainMenuActions+VanillaBrowser.swift"; sourceTree = ""; }; F41D174025CB131900472416 /* NSColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorExtension.swift; sourceTree = ""; }; F44C130125C2DA0400426E3E /* NSAppearanceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSAppearanceExtension.swift; sourceTree = ""; }; @@ -8430,7 +8438,7 @@ 4BB88B4425B7B55C006F6B06 /* DebugUserScript.swift */, 856CADEF271710F400E79BB0 /* HoverUserScript.swift */, 4B2E7D6226FF9D6500D2DB17 /* PrintingUserScript.swift */, - 1E0C72052ABC63BD00802009 /* SubscriptionPagesUserScript.swift */, + F1B33DF92BAD9C83001128B3 /* Subscription */, 1ED910D42B63BFB300936947 /* IdentityTheftRestorationPagesUserScript.swift */, 85AC3AEE25D5CE9800C7D2AA /* UserScripts.swift */, ); @@ -8653,6 +8661,16 @@ path = JSAlert; sourceTree = ""; }; + F1B33DF92BAD9C83001128B3 /* Subscription */ = { + isa = PBXGroup; + children = ( + 1E0C72052ABC63BD00802009 /* SubscriptionPagesUserScript.swift */, + F1B33DF12BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift */, + F1B33DF52BAD970E001128B3 /* SubscriptionErrorReporter.swift */, + ); + path = Subscription; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -10160,6 +10178,7 @@ B65211262B29A42E00B30633 /* BookmarkStoreMock.swift in Sources */, 3706FAF5293F65D500E42796 /* SafariVersionReader.swift in Sources */, 3706FAF6293F65D500E42796 /* LoginFaviconView.swift in Sources */, + F1B33DF72BAD970E001128B3 /* SubscriptionErrorReporter.swift in Sources */, 3706FEC0293F6EFF00E42796 /* BWRequest.swift in Sources */, 3706FAF7293F65D500E42796 /* FireproofDomainsViewController.swift in Sources */, 4BF0E5062AD2551A00FFEC9E /* NetworkProtectionPixelEvent.swift in Sources */, @@ -10577,6 +10596,7 @@ 3706FC1B293F65D500E42796 /* TabCollectionViewModel+NSSecureCoding.swift in Sources */, 3706FC1D293F65D500E42796 /* EmailManagerRequestDelegate.swift in Sources */, 3706FC1E293F65D500E42796 /* ApplicationVersionReader.swift in Sources */, + F1B33DF32BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift in Sources */, 3706FC1F293F65D500E42796 /* BookmarksBarViewController.swift in Sources */, 1DDC85042B83903E00670238 /* PreferencesWebTrackingProtectionView.swift in Sources */, 3706FC20293F65D500E42796 /* PreferencesAutofillView.swift in Sources */, @@ -11766,6 +11786,7 @@ 4B957B182AC7AE700062CA31 /* PreferencesDataClearingView.swift in Sources */, 4B957B192AC7AE700062CA31 /* NSPasteboardExtension.swift in Sources */, 4B957B1A2AC7AE700062CA31 /* OnboardingViewModel.swift in Sources */, + F1B33DF42BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift in Sources */, 4B957B1B2AC7AE700062CA31 /* ScriptSourceProviding.swift in Sources */, 4B957B1C2AC7AE700062CA31 /* CoreDataBookmarkImporter.swift in Sources */, 4B957B1D2AC7AE700062CA31 /* SuggestionViewModel.swift in Sources */, @@ -11883,6 +11904,7 @@ 4B957B7E2AC7AE700062CA31 /* NetworkProtection+ConvenienceInitializers.swift in Sources */, 7BA7CC502AD11F6F0042E5CE /* NetworkProtectionIPCTunnelController.swift in Sources */, 4B957B7F2AC7AE700062CA31 /* NavigationActionExtension.swift in Sources */, + F1B33DF82BAD970E001128B3 /* SubscriptionErrorReporter.swift in Sources */, 4B957B802AC7AE700062CA31 /* NSAlertExtension.swift in Sources */, 4B957B812AC7AE700062CA31 /* ThirdPartyBrowser.swift in Sources */, 4B957B822AC7AE700062CA31 /* SearchNonexistentDomainNavigationResponder.swift in Sources */, @@ -12520,6 +12542,7 @@ EE339228291BDEFD009F62C1 /* JSAlertController.swift in Sources */, 4B9DB04A2A983B24000927DB /* NotificationService.swift in Sources */, 3775912D29AAC72700E26367 /* SyncPreferences.swift in Sources */, + F1B33DF22BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift in Sources */, 1DB9618329F67F6200CF5568 /* FaviconNullStore.swift in Sources */, BB5789722B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift in Sources */, B693954F26F04BEB0015B914 /* PaddedImageButton.swift in Sources */, @@ -12652,6 +12675,7 @@ AA97BF4625135DD30014931A /* ApplicationDockMenu.swift in Sources */, 4B8A4DFF27C83B29005F40E8 /* SaveIdentityViewController.swift in Sources */, 1DDC84FB2B8356CE00670238 /* PreferencesDefaultBrowserView.swift in Sources */, + F1B33DF62BAD970E001128B3 /* SubscriptionErrorReporter.swift in Sources */, EEC589D92A4F1CE300BCD60C /* AppLauncher.swift in Sources */, 4BA1A69B258B076900F6F690 /* FileStore.swift in Sources */, B6A9E47F26146A800067D1B9 /* PixelArguments.swift in Sources */, diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme index f5feca4eef..359db57151 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme @@ -167,6 +167,26 @@ ReferencedContainer = "container:LocalPackages/PixelKit"> + + + + + + + + + + + + PreferencesSubscriptionModel { let openURL: (URL) -> Void = { url in DispatchQueue.main.async { @@ -179,7 +179,14 @@ enum Preferences { let sheetActionHandler = SubscriptionAccessActionHandlers(restorePurchases: { if #available(macOS 12.0, *) { - SubscriptionPagesUseSubscriptionFeature.startAppStoreRestoreFlow { _ in } + Task { + guard let mainViewController = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController, + let windowControllerManager = WindowControllersManager.shared.lastKeyMainWindowController else { + return + } + + await SubscriptionAppStoreRestorer.restoreAppStoreSubscription(mainViewController: mainViewController, windowController: windowControllerManager) + } } }, openURLHandler: openURL, diff --git a/DuckDuckGo/Statistics/DailyPixel.swift b/DuckDuckGo/Statistics/DailyPixel.swift index 9aade6d337..e436d453ba 100644 --- a/DuckDuckGo/Statistics/DailyPixel.swift +++ b/DuckDuckGo/Statistics/DailyPixel.swift @@ -66,6 +66,10 @@ final class DailyPixel { storage.set(Date(), forKey: pixel.name) } + static func clearLastFireDate(pixel: Pixel.Event) { + storage.removeObject(forKey: pixel.name) + } + } private extension Pixel.Event { diff --git a/DuckDuckGo/Statistics/Pixel.swift b/DuckDuckGo/Statistics/Pixel.swift index 4dddf4d740..30fde214bd 100644 --- a/DuckDuckGo/Statistics/Pixel.swift +++ b/DuckDuckGo/Statistics/Pixel.swift @@ -154,6 +154,9 @@ final class Pixel { onComplete: onComplete) } + func clearRepetitions(for event: Pixel.Event) { + store().removeValue(forKey: event.name) + } } public func pixelAssertionFailure(_ message: @autoclosure () -> String = String(), file: StaticString = #fileID, line: UInt = #line) { diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index 80a43dc6b8..695cc12e88 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -218,6 +218,9 @@ extension Pixel { case dataBrokerProtectionErrorWhenFetchingSubscriptionAuthTokenAfterSignIn // Subscription + case privacyProFeatureEnabled + case privacyProBetaUserThankYouVPN + case privacyProBetaUserThankYouDBP case privacyProSubscriptionActive case privacyProOfferScreenImpression case privacyProPurchaseAttempt @@ -250,6 +253,7 @@ extension Pixel { case privacyProSubscriptionManagementEmail case privacyProSubscriptionManagementPlanBilling case privacyProSubscriptionManagementRemoval + case privacyProPurchaseStripeSuccess // Web pixels case privacyProOfferMonthlyPriceClick case privacyProOfferYearlyPriceClick @@ -655,6 +659,10 @@ extension Pixel.Event { case .defaultRequestedFromOnboarding: return "m_mac_default_requested_from_onboarding" // MARK: - Subscription + case .privacyProFeatureEnabled: return + "m_mac_\(appDistribution)_privacy-pro_feature_enabled" + case .privacyProBetaUserThankYouVPN: return "m_mac_\(appDistribution)_privacy-pro_promotion-dialog_shown_vpn" + case .privacyProBetaUserThankYouDBP: return "m_mac_\(appDistribution)_privacy-pro_promotion-dialog_shown_dbp" case .privacyProSubscriptionActive: return "m_mac_\(appDistribution)_privacy-pro_app_subscription_active" case .privacyProOfferScreenImpression: return "m_mac_\(appDistribution)_privacy-pro_offer_screen_impression" case .privacyProPurchaseAttempt: return "m_mac_\(appDistribution)_privacy-pro_terms-conditions_subscribe_click" @@ -687,6 +695,7 @@ extension Pixel.Event { case .privacyProSubscriptionManagementEmail: return "m_mac_\(appDistribution)_privacy-pro_manage-email_edit_click" case .privacyProSubscriptionManagementPlanBilling: return "m_mac_\(appDistribution)_privacy-pro_settings_change-plan-or-billing_click" case .privacyProSubscriptionManagementRemoval: return "m_mac_\(appDistribution)_privacy-pro_settings_remove-from-device_click" + case .privacyProPurchaseStripeSuccess: return "m_mac_\(appDistribution)_privacy-pro_app_subscription-purchase_stripe_success" // Web case .privacyProOfferMonthlyPriceClick: return "m_mac_\(appDistribution)_privacy-pro_offer_monthly-price_click" case .privacyProOfferYearlyPriceClick: return "m_mac_\(appDistribution)_privacy-pro_offer_yearly-price_click" @@ -701,7 +710,6 @@ extension Pixel.Event { // Password Import Keychain Prompt case .passwordImportKeychainPrompt: return "m_mac_password_import_keychain_prompt" case .passwordImportKeychainPromptDenied: return "m_mac_password_import_keychain_prompt_denied" - } } } diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index 9238a76d77..afbd32473f 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -170,6 +170,9 @@ extension Pixel.Event { .defaultRequestedFromHomepageSetupView, .defaultRequestedFromSettings, .defaultRequestedFromOnboarding, + .privacyProFeatureEnabled, + .privacyProBetaUserThankYouVPN, + .privacyProBetaUserThankYouDBP, .privacyProSubscriptionActive, .privacyProOfferScreenImpression, .privacyProPurchaseAttempt, @@ -210,6 +213,7 @@ extension Pixel.Event { .privacyProOfferYearlyPriceClick, .privacyProAddEmailSuccess, .privacyProWelcomeFAQClick, + .privacyProPurchaseStripeSuccess, .passwordImportKeychainPrompt, .passwordImportKeychainPromptDenied: return nil diff --git a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionAppStoreRestorer.swift b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionAppStoreRestorer.swift new file mode 100644 index 0000000000..61375ff8c0 --- /dev/null +++ b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionAppStoreRestorer.swift @@ -0,0 +1,69 @@ +// +// SubscriptionAppStoreRestorer.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Subscription +import SubscriptionUI + +@available(macOS 12.0, *) +struct SubscriptionAppStoreRestorer { + + static func restoreAppStoreSubscription(mainViewController: MainViewController, windowController: MainWindowController) async { + + let progressViewController = await ProgressViewController(title: UserText.restoringSubscriptionTitle) + defer { + DispatchQueue.main.async { + mainViewController.dismiss(progressViewController) + } + } + + DispatchQueue.main.async { + mainViewController.presentAsSheet(progressViewController) + } + + guard case .success = await PurchaseManager.shared.syncAppleIDAccount() else { + return + } + + let result = await AppStoreRestoreFlow.restoreAccountFromPastPurchase(subscriptionAppGroup: Bundle.main.appGroup(bundle: .subs)) + + switch result { + case .success: + DailyPixel.fire(pixel: .privacyProRestorePurchaseStoreSuccess, frequency: .dailyAndCount) + + case .failure(let error): + switch error { + case .missingAccountOrTransactions: break + default: + DailyPixel.fire(pixel: .privacyProRestorePurchaseStoreFailureOther, frequency: .dailyAndCount) + } + + switch error { + case .missingAccountOrTransactions: + SubscriptionErrorReporter.report(subscriptionActivationError: .subscriptionNotFound) + await windowController.showSubscriptionNotFoundAlert() + case .subscriptionExpired: + SubscriptionErrorReporter.report(subscriptionActivationError: .subscriptionExpired) + await windowController.showSubscriptionInactiveAlert() + case .pastTransactionAuthenticationError, .failedToObtainAccessToken, .failedToFetchAccountDetails, .failedToFetchSubscriptionDetails: + SubscriptionErrorReporter.report(subscriptionActivationError: .generalError) + await windowController.showSomethingWentWrongAlert() + } + } + } +} diff --git a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionErrorReporter.swift b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionErrorReporter.swift new file mode 100644 index 0000000000..bdce936804 --- /dev/null +++ b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionErrorReporter.swift @@ -0,0 +1,85 @@ +// +// SubscriptionErrorReporter.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Common + +enum SubscriptionError: Error { + case purchaseFailed, + missingEntitlements, + failedToGetSubscriptionOptions, + failedToSetSubscription, + failedToRestoreFromEmail, + failedToRestoreFromEmailSubscriptionInactive, + failedToRestorePastPurchase, + subscriptionNotFound, + subscriptionExpired, + hasActiveSubscription, + cancelledByUser, + accountCreationFailed, + activeSubscriptionAlreadyPresent, + generalError +} + +struct SubscriptionErrorReporter { + + // swiftlint:disable:next cyclomatic_complexity + static func report(subscriptionActivationError: SubscriptionError) { + + os_log(.error, log: .subscription, "Subscription purchase error: %{public}s", subscriptionActivationError.localizedDescription) + + var isStoreError = false + var isBackendError = false + + switch subscriptionActivationError { + case .purchaseFailed: + isStoreError = true + case .missingEntitlements: + isBackendError = true + case .failedToGetSubscriptionOptions: + isStoreError = true + case .failedToSetSubscription: + isBackendError = true + case .failedToRestoreFromEmail, .failedToRestoreFromEmailSubscriptionInactive: + isBackendError = true + case .failedToRestorePastPurchase: + isStoreError = true + case .subscriptionNotFound: + DailyPixel.fire(pixel: .privacyProRestorePurchaseStoreFailureNotFound, frequency: .dailyAndCount) + isStoreError = true + case .subscriptionExpired: + isStoreError = true + case .hasActiveSubscription: + isStoreError = true + isBackendError = true + case .cancelledByUser: break + case .accountCreationFailed: + DailyPixel.fire(pixel: .privacyProPurchaseFailureAccountNotCreated, frequency: .dailyAndCount) + case .activeSubscriptionAlreadyPresent: break + case .generalError: break + } + + if isStoreError { + DailyPixel.fire(pixel: .privacyProPurchaseFailureStoreError, frequency: .dailyAndCount) + } + + if isBackendError { + DailyPixel.fire(pixel: .privacyProPurchaseFailureBackendError, frequency: .dailyAndCount) + } + } +} diff --git a/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUserScript.swift similarity index 72% rename from DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift rename to DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUserScript.swift index f270783d8d..daf9de5b3c 100644 --- a/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift +++ b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUserScript.swift @@ -232,7 +232,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { guard let subscriptionSelection: SubscriptionSelection = DecodableHelper.decode(from: params) else { assertionFailure("SubscriptionPagesUserScript: expected JSON representation of SubscriptionSelection") - report(subscriptionActivationError: .generalError) + SubscriptionErrorReporter.report(subscriptionActivationError: .generalError) return nil } @@ -246,7 +246,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { Pixel.fire(.privacyProRestoreAfterPurchaseAttempt) os_log(.info, log: .subscription, "[Purchase] Found active subscription during purchase") - report(subscriptionActivationError: .hasActiveSubscription) + SubscriptionErrorReporter.report(subscriptionActivationError: .hasActiveSubscription) await WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionFoundAlert(originalMessage: message) return nil } @@ -261,19 +261,19 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { case .failure(let error): switch error { case .noProductsFound: - report(subscriptionActivationError: .subscriptionNotFound) + SubscriptionErrorReporter.report(subscriptionActivationError: .subscriptionNotFound) case .activeSubscriptionAlreadyPresent: - report(subscriptionActivationError: .activeSubscriptionAlreadyPresent) + SubscriptionErrorReporter.report(subscriptionActivationError: .activeSubscriptionAlreadyPresent) case .authenticatingWithTransactionFailed: - report(subscriptionActivationError: .generalError) + SubscriptionErrorReporter.report(subscriptionActivationError: .generalError) case .accountCreationFailed: - report(subscriptionActivationError: .accountCreationFailed) + SubscriptionErrorReporter.report(subscriptionActivationError: .accountCreationFailed) case .purchaseFailed: - report(subscriptionActivationError: .purchaseFailed) + SubscriptionErrorReporter.report(subscriptionActivationError: .purchaseFailed) case .cancelledByUser: - report(subscriptionActivationError: .cancelledByUser) + SubscriptionErrorReporter.report(subscriptionActivationError: .cancelledByUser) case .missingEntitlements: - report(subscriptionActivationError: .missingEntitlements) + SubscriptionErrorReporter.report(subscriptionActivationError: .missingEntitlements) } if error != .cancelledByUser { @@ -296,19 +296,19 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { case .failure(let error): switch error { case .noProductsFound: - report(subscriptionActivationError: .subscriptionNotFound) + SubscriptionErrorReporter.report(subscriptionActivationError: .subscriptionNotFound) case .activeSubscriptionAlreadyPresent: - report(subscriptionActivationError: .activeSubscriptionAlreadyPresent) + SubscriptionErrorReporter.report(subscriptionActivationError: .activeSubscriptionAlreadyPresent) case .authenticatingWithTransactionFailed: - report(subscriptionActivationError: .generalError) + SubscriptionErrorReporter.report(subscriptionActivationError: .generalError) case .accountCreationFailed: - report(subscriptionActivationError: .accountCreationFailed) + SubscriptionErrorReporter.report(subscriptionActivationError: .accountCreationFailed) case .purchaseFailed: - report(subscriptionActivationError: .purchaseFailed) + SubscriptionErrorReporter.report(subscriptionActivationError: .purchaseFailed) case .cancelledByUser: - report(subscriptionActivationError: .cancelledByUser) + SubscriptionErrorReporter.report(subscriptionActivationError: .cancelledByUser) case .missingEntitlements: - report(subscriptionActivationError: .missingEntitlements) + SubscriptionErrorReporter.report(subscriptionActivationError: .missingEntitlements) } await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: PurchaseUpdate(type: "completed")) @@ -327,9 +327,9 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { switch error { case .noProductsFound: - report(subscriptionActivationError: .subscriptionNotFound) + SubscriptionErrorReporter.report(subscriptionActivationError: .subscriptionNotFound) case .accountCreationFailed: - report(subscriptionActivationError: .accountCreationFailed) + SubscriptionErrorReporter.report(subscriptionActivationError: .accountCreationFailed) } await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: PurchaseUpdate(type: "canceled")) } @@ -338,128 +338,37 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { return nil } - fileprivate enum SubscriptionError: Error { - case purchaseFailed, - missingEntitlements, - failedToGetSubscriptionOptions, - failedToSetSubscription, - failedToRestoreFromEmail, - failedToRestoreFromEmailSubscriptionInactive, - failedToRestorePastPurchase, - subscriptionNotFound, - subscriptionExpired, - hasActiveSubscription, - cancelledByUser, - accountCreationFailed, - activeSubscriptionAlreadyPresent, - generalError - } - - // swiftlint:disable:next cyclomatic_complexity - fileprivate func report(subscriptionActivationError: SubscriptionError) { - - os_log(.error, log: .subscription, "Subscription purchase error: %{public}s", subscriptionActivationError.localizedDescription) - - var isStoreError = false - var isBackendError = false - - switch subscriptionActivationError { - case .purchaseFailed: - isStoreError = true - case .missingEntitlements: - isBackendError = true - case .failedToGetSubscriptionOptions: - isStoreError = true - case .failedToSetSubscription: - isBackendError = true - case .failedToRestoreFromEmail, .failedToRestoreFromEmailSubscriptionInactive: - isBackendError = true - case .failedToRestorePastPurchase: - isStoreError = true - case .subscriptionNotFound: - DailyPixel.fire(pixel: .privacyProRestorePurchaseStoreFailureNotFound, frequency: .dailyAndCount) - isStoreError = true - case .subscriptionExpired: - isStoreError = true - case .hasActiveSubscription: - isStoreError = true - isBackendError = true - case .cancelledByUser: break - case .accountCreationFailed: - DailyPixel.fire(pixel: .privacyProPurchaseFailureAccountNotCreated, frequency: .dailyAndCount) - case .activeSubscriptionAlreadyPresent: break - case .generalError: break - } - - if isStoreError { - DailyPixel.fire(pixel: .privacyProPurchaseFailureStoreError, frequency: .dailyAndCount) - } - - if isBackendError { - DailyPixel.fire(pixel: .privacyProPurchaseFailureBackendError, frequency: .dailyAndCount) - } - - if subscriptionActivationError != .hasActiveSubscription && subscriptionActivationError != .cancelledByUser { - DailyPixel.fire(pixel: .privacyProPurchaseFailure, frequency: .dailyAndCount) - } - } - func activateSubscription(params: Any, original: WKScriptMessage) async throws -> Encodable? { Pixel.fire(.privacyProRestorePurchaseOfferPageEntry) - + guard let mainViewController = await WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController, + let windowControllerManager = await WindowControllersManager.shared.lastKeyMainWindowController else { + return nil + } let message = original - Task { @MainActor in - - let actionHandlers = SubscriptionAccessActionHandlers( - restorePurchases: { - - if #available(macOS 12.0, *) { - Self.startAppStoreRestoreFlow { result in - - switch result { - case .success: - DailyPixel.fire(pixel: .privacyProRestorePurchaseStoreSuccess, frequency: .dailyAndCount) - - case .failure(let error): - - switch error { - case .missingAccountOrTransactions: break - default: - DailyPixel.fire(pixel: .privacyProRestorePurchaseStoreFailureOther, frequency: .dailyAndCount) - } - - switch error { - case .missingAccountOrTransactions: - self.report(subscriptionActivationError: .subscriptionNotFound) - WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionNotFoundAlert() - case .subscriptionExpired: - self.report(subscriptionActivationError: .subscriptionExpired) - WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionInactiveAlert() - case .pastTransactionAuthenticationError, .failedToObtainAccessToken, .failedToFetchAccountDetails, .failedToFetchSubscriptionDetails: - self.report(subscriptionActivationError: .generalError) - WindowControllersManager.shared.lastKeyMainWindowController?.showSomethingWentWrongAlert() - } - } - message.webView?.reload() - } - } - }, - openURLHandler: { url in - WindowControllersManager.shared.showTab(with: .subscription(url)) - }, uiActionHandler: { event in - switch event { - case .activateAddEmailClick: - DailyPixel.fire(pixel: .privacyProRestorePurchaseEmailStart, frequency: .dailyAndCount) - default: - break - } - }) + let actionHandlers = SubscriptionAccessActionHandlers(restorePurchases: { + if #available(macOS 12.0, *) { + Task { @MainActor in + await SubscriptionAppStoreRestorer.restoreAppStoreSubscription(mainViewController: mainViewController, windowController: windowControllerManager) + message.webView?.reload() + } + } + }, openURLHandler: { url in + DispatchQueue.main.async { + WindowControllersManager.shared.showTab(with: .subscription(url)) + } + }, uiActionHandler: { event in + switch event { + case .activateAddEmailClick: + DailyPixel.fire(pixel: .privacyProRestorePurchaseEmailStart, frequency: .dailyAndCount) + default: + break + } + }) - let vc = SubscriptionAccessViewController(accountManager: AccountManager(), actionHandlers: actionHandlers, subscriptionAppGroup: Bundle.main.appGroup(bundle: .subs)) - WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.presentAsSheet(vc) - } + let vc = await SubscriptionAccessViewController(accountManager: AccountManager(), actionHandlers: actionHandlers, subscriptionAppGroup: Bundle.main.appGroup(bundle: .subs)) + await WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.presentAsSheet(vc) return nil } @@ -511,7 +420,9 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { await StripePurchaseFlow.completeSubscriptionPurchase(subscriptionAppGroup: Bundle.main.appGroup(bundle: .subs)) await mainViewController?.dismiss(progressViewController) - return [String: String]() // cannot be nil + DailyPixel.fire(pixel: .privacyProPurchaseStripeSuccess, frequency: .dailyAndCount) + + return [String: String]() // cannot be nil, the web app expect something back before redirecting the user to the final page } // MARK: Pixel related actions @@ -558,25 +469,11 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { } } -extension SubscriptionPagesUseSubscriptionFeature { - - @available(macOS 12.0, *) - static func startAppStoreRestoreFlow(onResultHandler: @escaping (Result) -> Void = {_ in}) { - Task { @MainActor in - let mainViewController = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController - let progressViewController = ProgressViewController(title: UserText.restoringSubscriptionTitle) - defer { mainViewController?.dismiss(progressViewController) } - mainViewController?.presentAsSheet(progressViewController) - guard case .success = await PurchaseManager.shared.syncAppleIDAccount() else { return } - onResultHandler(await AppStoreRestoreFlow.restoreAccountFromPastPurchase(subscriptionAppGroup: Bundle.main.appGroup(bundle: .subs))) - } - } -} - extension MainWindowController { @MainActor func showSomethingWentWrongAlert() { + DailyPixel.fire(pixel: .privacyProPurchaseFailure, frequency: .dailyAndCount) guard let window else { return } window.show(.somethingWentWrongAlert()) @@ -588,6 +485,7 @@ extension MainWindowController { window.show(.subscriptionNotFoundAlert(), firstButtonAction: { WindowControllersManager.shared.showTab(with: .subscription(.subscriptionPurchase)) + Pixel.fire(.privacyProOfferScreenImpression) }) } @@ -597,6 +495,7 @@ extension MainWindowController { window.show(.subscriptionInactiveAlert(), firstButtonAction: { WindowControllersManager.shared.showTab(with: .subscription(.subscriptionPurchase)) + Pixel.fire(.privacyProOfferScreenImpression) }) } diff --git a/DuckDuckGo/Waitlist/NetworkProtectionFeatureVisibility.swift b/DuckDuckGo/Waitlist/NetworkProtectionFeatureVisibility.swift index 255dbd6dff..e289dc4655 100644 --- a/DuckDuckGo/Waitlist/NetworkProtectionFeatureVisibility.swift +++ b/DuckDuckGo/Waitlist/NetworkProtectionFeatureVisibility.swift @@ -25,6 +25,7 @@ import NetworkExtension import NetworkProtection import NetworkProtectionUI import LoginItems +import PixelKit #if SUBSCRIPTION import Subscription @@ -173,6 +174,7 @@ struct DefaultNetworkProtectionVisibility: NetworkProtectionFeatureVisibility { return false } + PixelKit.fire(VPNPrivacyProPixel.vpnBetaStoppedWhenPrivacyProEnabled, frequency: .dailyAndContinuous) defaults.vpnLegacyUserAccessDisabledOnce = true await featureDisabler.disable(keepAuthToken: true, uninstallSystemExtension: false) return true diff --git a/DuckDuckGo/Waitlist/Views/WaitlistThankYouPromptPresenter.swift b/DuckDuckGo/Waitlist/Views/WaitlistThankYouPromptPresenter.swift index 90555f5a3e..07155802a3 100644 --- a/DuckDuckGo/Waitlist/Views/WaitlistThankYouPromptPresenter.swift +++ b/DuckDuckGo/Waitlist/Views/WaitlistThankYouPromptPresenter.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import AppKit import Foundation final class WaitlistThankYouPromptPresenter { @@ -50,15 +51,23 @@ final class WaitlistThankYouPromptPresenter { // If the user tested both, the PIR prompt will be displayed. @MainActor func presentThankYouPromptIfNecessary(in window: NSWindow) { + // Wiring this here since it's mostly useful for rolling out PrivacyPro, and should + // go away once PPro is fully rolled out. + if NSApp.delegateTyped.subscriptionFeatureAvailability.isFeatureAvailable { + DailyPixel.fire(pixel: .privacyProFeatureEnabled, frequency: .dailyOnly) + } + guard canShowPromptCheck() else { return } if isPIRBetaTester() { saveDidShowPromptCheck() + DailyPixel.fire(pixel: Pixel.Event.privacyProBetaUserThankYouDBP, frequency: .dailyAndCount) presentPIRThankYouPrompt(in: window) } else if isVPNBetaTester() { saveDidShowPromptCheck() + DailyPixel.fire(pixel: Pixel.Event.privacyProBetaUserThankYouVPN, frequency: .dailyAndCount) presentVPNThankYouPrompt(in: window) } } diff --git a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift index c44f70bbd7..630569a807 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift +++ b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift @@ -371,6 +371,8 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { UserDefaults.netP.networkProtectionEntitlementsExpired = false case .invalidEntitlement: UserDefaults.netP.networkProtectionEntitlementsExpired = true + PixelKit.fire(VPNPrivacyProPixel.vpnAccessRevokedDialogShown, frequency: .dailyAndContinuous) + guard let self else { return } Task { let isConnected = await self.tunnelController.isConnected diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index 7a42ba6481..dfbbd04a7e 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -28,7 +28,7 @@ let package = Package( products: [ .library(name: "NetworkProtectionIPC", targets: ["NetworkProtectionIPC"]), .library(name: "NetworkProtectionProxy", targets: ["NetworkProtectionProxy"]), - .library(name: "NetworkProtectionUI", targets: ["NetworkProtectionUI"]) + .library(name: "NetworkProtectionUI", targets: ["NetworkProtectionUI"]), ], dependencies: [ .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "129.1.0"), @@ -75,6 +75,7 @@ let package = Package( .product(name: "NetworkProtection", package: "BrowserServicesKit"), .product(name: "SwiftUIExtensions", package: "SwiftUIExtensions"), .product(name: "LoginItems", package: "LoginItems"), + .product(name: "PixelKit", package: "PixelKit"), ], resources: [ .copy("Resources/Assets.xcassets") @@ -84,14 +85,16 @@ let package = Package( ], plugins: [.plugin(name: "SwiftLintPlugin", package: "apple-toolbox")] ), + .testTarget( name: "NetworkProtectionUITests", dependencies: [ "NetworkProtectionUI", .product(name: "NetworkProtectionTestUtils", package: "BrowserServicesKit"), .product(name: "LoginItems", package: "LoginItems"), + .product(name: "PixelKitTestingUtilities", package: "PixelKit"), ], plugins: [.plugin(name: "SwiftLintPlugin", package: "apple-toolbox")] - ) + ), ] ) diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Pixels/VPNPrivacyProPixel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Pixels/VPNPrivacyProPixel.swift new file mode 100644 index 0000000000..2bbbf3a464 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Pixels/VPNPrivacyProPixel.swift @@ -0,0 +1,52 @@ +// +// VPNPrivacyProPixel.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import PixelKit + +/// PrivacyPro pixels. +/// +/// Ref: https://app.asana.com/0/0/1206836019887720/f +/// +public enum VPNPrivacyProPixel: PixelKitEventV2 { + + /// Fired when PrivacyPro VPN access is revoked, and the dialog is shown. + /// + case vpnAccessRevokedDialogShown + + /// Fired only once when the VPN beta becomes disabled due to the start of PrivacyPro.. + /// + case vpnBetaStoppedWhenPrivacyProEnabled + + public var name: String { + switch self { + case .vpnAccessRevokedDialogShown: + return "vpn_access_revoked_dialog_shown" + case .vpnBetaStoppedWhenPrivacyProEnabled: + return "vpn_beta_stopped_when_privacy_pro_enabled" + } + } + + public var error: Error? { + nil + } + + public var parameters: [String: String]? { + nil + } +} diff --git a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/Pixels/VPNPrivacyProPixelTests.swift b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/Pixels/VPNPrivacyProPixelTests.swift new file mode 100644 index 0000000000..05e05a5827 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/Pixels/VPNPrivacyProPixelTests.swift @@ -0,0 +1,72 @@ +// +// VPNPrivacyProPixelTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import PixelKit +import PixelKitTestingUtilities +import XCTest +@testable import NetworkProtectionUI + +final class VPNPrivacyProPixelTests: XCTestCase { + + private enum TestError: CustomNSError { + case testError + case underlyingError + + /// The domain of the error. + static var errorDomain: String { + "testDomain" + } + + /// The error code within the given domain. + var errorCode: Int { + switch self { + case .testError: return 1 + case .underlyingError: return 2 + } + } + + /// The user-info dictionary. + var errorUserInfo: [String: Any] { + switch self { + case .testError: + return [NSUnderlyingErrorKey: TestError.underlyingError] + case .underlyingError: + return [:] + } + } + } + + // MARK: - Test Firing Pixels + + /// This test verifies validates expectations when firing `VPNPrivacyProPixel`. + /// + /// This test verifies a few different things: + /// - That the pixel name is not changed by mistake. + /// - That when the pixel is fired its name and parameters are exactly what's expected. + /// + func testVPNPixelFireExpectations() { + fire(VPNPrivacyProPixel.vpnAccessRevokedDialogShown, + and: .expect(pixelName: "m_mac_vpn_access_revoked_dialog_shown"), + file: #filePath, + line: #line) + fire(VPNPrivacyProPixel.vpnBetaStoppedWhenPrivacyProEnabled, + and: .expect(pixelName: "m_mac_vpn_beta_stopped_when_privacy_pro_enabled"), + file: #filePath, + line: #line) + } +} diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift index ff2628ae29..6e1f86a845 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift @@ -200,6 +200,7 @@ public final class PreferencesSubscriptionModel: ObservableObject { @MainActor func removeFromThisDeviceAction() { + userEventHandler(.removeSubscriptionClick) accountManager.signOut() } diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift index 52a723b011..db764f58e1 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift @@ -125,7 +125,6 @@ public struct PreferencesSubscriptionView: View { } }) Button(UserText.removeFromThisDeviceButton, action: { - model.userEventHandler(.removeSubscriptionClick) showingRemoveConfirmationDialog.toggle() }) } label: { @@ -190,7 +189,6 @@ public struct PreferencesSubscriptionView: View { .buttonStyle(DefaultActionButtonStyle(enabled: true)) */ Menu { Button(UserText.removeFromThisDeviceButton, action: { - model.userEventHandler(.removeSubscriptionClick) showingRemoveConfirmationDialog.toggle() }) } label: {