diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index e6116d27fe..b0ca07d341 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1065,6 +1065,12 @@ 4B0511CA262CAA5A00F6079C /* FireproofDomainsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511B4262CAA5A00F6079C /* FireproofDomainsViewController.swift */; }; 4B0511E1262CAA8600F6079C /* NSOpenPanelExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511DF262CAA8600F6079C /* NSOpenPanelExtensions.swift */; }; 4B0511E2262CAA8600F6079C /* NSViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511E0262CAA8600F6079C /* NSViewControllerExtension.swift */; }; + 4B05265E2B1AE5C70054955A /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */; }; + 4B05265F2B1AEFDB0054955A /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */; }; + 4B0526612B1D55320054955A /* VPNFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */; }; + 4B0526622B1D55320054955A /* VPNFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */; }; + 4B0526642B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */; }; + 4B0526652B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */; }; 4B0A63E8289DB58E00378EF7 /* FirefoxFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0A63E7289DB58E00378EF7 /* FirefoxFaviconsReader.swift */; }; 4B0AACAC28BC63ED001038AC /* ChromiumFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0AACAB28BC63ED001038AC /* ChromiumFaviconsReader.swift */; }; 4B0AACAE28BC6FD0001038AC /* SafariFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0AACAD28BC6FD0001038AC /* SafariFaviconsReader.swift */; }; @@ -1122,8 +1128,20 @@ 4B41EDA82B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */; }; 4B41EDA92B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */; }; 4B41EDAB2B1544B2001EEDF4 /* LoginItems in Frameworks */ = {isa = PBXBuildFile; productRef = 4B41EDAA2B1544B2001EEDF4 /* LoginItems */; }; + 4B41EDAE2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */; }; + 4B41EDAF2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */; }; + 4B41EDB12B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */; }; + 4B41EDB22B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */; }; + 4B41EDB42B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; + 4B41EDB52B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; + 4B41EDB62B169883001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */; }; + 4B41EDB72B169887001EEDF4 /* VPNFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */; }; + 4B41EDB82B169889001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; 4B434690285ED7A100177407 /* BookmarksBarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43468F285ED7A100177407 /* BookmarksBarViewModelTests.swift */; }; 4B43469528655D1400177407 /* FirefoxDataImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */; }; + 4B44FEF32B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */; }; + 4B44FEF42B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */; }; + 4B44FEF52B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */; }; 4B4BEC3D2A11B56B001D9AC5 /* DuckDuckGoNotificationsAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4BEC382A11B509001D9AC5 /* DuckDuckGoNotificationsAppDelegate.swift */; }; 4B4BEC3E2A11B56E001D9AC5 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4BEC322A11B509001D9AC5 /* Logging.swift */; }; 4B4BEC402A11B5B5001D9AC5 /* NetworkProtectionExtensionMachService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4D60782A0B29FA00BCD287 /* NetworkProtectionExtensionMachService.swift */; }; @@ -2102,6 +2120,8 @@ 4BD57C052AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */; }; 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */; }; 4BE0DF06267819A1006337B7 /* NSStoryboardExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE0DF0426781961006337B7 /* NSStoryboardExtension.swift */; }; + 4BE344EE2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */; }; + 4BE344EF2B23786F003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */; }; 4BE4005327CF3DC3007D3161 /* SavePaymentMethodPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE4005227CF3DC3007D3161 /* SavePaymentMethodPopover.swift */; }; 4BE4005527CF3F19007D3161 /* SavePaymentMethodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE4005427CF3F19007D3161 /* SavePaymentMethodViewController.swift */; }; 4BE41A5E28446EAD00760399 /* BookmarksBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */; }; @@ -3301,6 +3321,9 @@ 4B0511B4262CAA5A00F6079C /* FireproofDomainsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FireproofDomainsViewController.swift; sourceTree = ""; }; 4B0511DF262CAA8600F6079C /* NSOpenPanelExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSOpenPanelExtensions.swift; sourceTree = ""; }; 4B0511E0262CAA8600F6079C /* NSViewControllerExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSViewControllerExtension.swift; sourceTree = ""; }; + 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNMetadataCollector.swift; sourceTree = ""; }; + 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackSender.swift; sourceTree = ""; }; + 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackCategory.swift; sourceTree = ""; }; 4B0A63E7289DB58E00378EF7 /* FirefoxFaviconsReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxFaviconsReader.swift; sourceTree = ""; }; 4B0AACAB28BC63ED001038AC /* ChromiumFaviconsReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumFaviconsReader.swift; sourceTree = ""; }; 4B0AACAD28BC6FD0001038AC /* SafariFaviconsReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariFaviconsReader.swift; sourceTree = ""; }; @@ -3338,8 +3361,12 @@ 4B41ED9F2B15437A001EEDF4 /* NetworkProtectionNotificationsPresenterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionNotificationsPresenterFactory.swift; sourceTree = ""; }; 4B41EDA22B1543B9001EEDF4 /* VPNPreferencesModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNPreferencesModel.swift; sourceTree = ""; }; 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesVPNView.swift; sourceTree = ""; }; + 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewController.swift; sourceTree = ""; }; + 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormView.swift; sourceTree = ""; }; + 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModel.swift; sourceTree = ""; }; 4B43468F285ED7A100177407 /* BookmarksBarViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarViewModelTests.swift; sourceTree = ""; }; 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxDataImporterTests.swift; sourceTree = ""; }; + 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextEditor.swift; sourceTree = ""; }; 4B4BEC182A11B3EA001D9AC5 /* DuckDuckGoNotifications.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DuckDuckGoNotifications.xcconfig; sourceTree = ""; }; 4B4BEC202A11B4E2001D9AC5 /* DuckDuckGo Notifications.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "DuckDuckGo Notifications.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 4B4BEC322A11B509001D9AC5 /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; @@ -3541,6 +3568,7 @@ 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableScrollView.swift; sourceTree = ""; }; 4BE0DF0426781961006337B7 /* NSStoryboardExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSStoryboardExtension.swift; sourceTree = ""; }; 4BE15DB12A0B0DD500898243 /* PixelKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PixelKit; sourceTree = ""; }; + 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModelTests.swift; sourceTree = ""; }; 4BE4005227CF3DC3007D3161 /* SavePaymentMethodPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePaymentMethodPopover.swift; sourceTree = ""; }; 4BE4005427CF3F19007D3161 /* SavePaymentMethodViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePaymentMethodViewController.swift; sourceTree = ""; }; 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarViewModel.swift; sourceTree = ""; }; @@ -5034,6 +5062,19 @@ path = DeviceAuthentication; sourceTree = ""; }; + 4B41EDAC2B168A66001EEDF4 /* VPNFeedbackForm */ = { + isa = PBXGroup; + children = ( + 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */, + 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */, + 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */, + 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */, + 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */, + 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */, + ); + path = VPNFeedbackForm; + sourceTree = ""; + }; 4B43468D285ED6BD00177407 /* BookmarksBar */ = { isa = PBXGroup; children = ( @@ -5712,6 +5753,14 @@ path = View; sourceTree = ""; }; + 4BE344EC2B2376AE003FC223 /* VPNFeedbackForm */ = { + isa = PBXGroup; + children = ( + 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */, + ); + path = VPNFeedbackForm; + sourceTree = ""; + }; 4BF6961B28BE90E800D402D4 /* HomePage */ = { isa = PBXGroup; children = ( @@ -5950,6 +5999,7 @@ 31C3CE0128EDC1E70002C24A /* CustomRoundedCornersShape.swift */, 378F44EA29B4C73E00899924 /* View+RoundedCorners.swift */, B690152B2ACBF4DA00AD0BAB /* MenuPreview.swift */, + 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */, ); path = SwiftUI; sourceTree = ""; @@ -6395,6 +6445,7 @@ AA97BF4425135CB60014931A /* Menus */, 85378D9A274E618C007C5CBF /* MessageViews */, AA86491524D83384001BABEE /* NavigationBar */, + 4B41EDAC2B168A66001EEDF4 /* VPNFeedbackForm */, 4B4D60542A0B29FA00BCD287 /* NetworkProtection */, 85B7184727677A7D00B4277F /* Onboarding */, 1D074B252909A371006E4AC3 /* PasswordManager */, @@ -6480,6 +6531,7 @@ 4B9DB04D2A983B55000927DB /* Waitlist */, 3776582B27F7163B009A6B35 /* WebsiteBreakageReport */, 376718FE28E58504003A2A15 /* YoutubePlayer */, + 4BE344EC2B2376AE003FC223 /* VPNFeedbackForm */, AA585D96248FD31400E9A3E2 /* Info.plist */, ); path = UnitTests; @@ -9139,6 +9191,7 @@ 3706FAA1293F65D500E42796 /* NSAlert+DataImport.swift in Sources */, 3706FAA2293F65D500E42796 /* MainWindow.swift in Sources */, 3707C727294B5D2900682A9F /* WKWebView+SessionState.swift in Sources */, + 4B44FEF42B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */, 3706FAA3293F65D500E42796 /* CrashReportPromptViewController.swift in Sources */, 3706FAA4293F65D500E42796 /* ContextMenuManager.swift in Sources */, 3706FAA5293F65D500E42796 /* GradientView.swift in Sources */, @@ -9458,6 +9511,7 @@ 3706FBA4293F65D500E42796 /* ContentOverlayPopover.swift in Sources */, 3706FBA5293F65D500E42796 /* TabShadowView.swift in Sources */, 3706FBA7293F65D500E42796 /* EncryptedValueTransformer.swift in Sources */, + 4B41EDAF2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, 3706FBA8293F65D500E42796 /* PasteboardBookmark.swift in Sources */, 3706FBA9293F65D500E42796 /* PinnedTabsManager.swift in Sources */, B66260E829ACD0C900E9E3EE /* DuckPlayerTabExtension.swift in Sources */, @@ -9521,6 +9575,7 @@ 3706FBDA293F65D500E42796 /* PermissionManager.swift in Sources */, 3706FBDB293F65D500E42796 /* DefaultBrowserPreferences.swift in Sources */, 3706FBDC293F65D500E42796 /* Permissions.xcdatamodeld in Sources */, + 4B41EDB52B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, 3706FBDD293F65D500E42796 /* PaddedImageButton.swift in Sources */, 37CEFCAA2A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */, 4BCF15F02ABBDC010083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */, @@ -9697,6 +9752,7 @@ 3706FC69293F65D500E42796 /* UserScripts.swift in Sources */, 3706FC6A293F65D500E42796 /* NSWorkspaceExtension.swift in Sources */, B6C0BB6829AEFF8100AE8E3C /* BookmarkExtension.swift in Sources */, + 4B41EDB22B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 3706FC6C293F65D500E42796 /* BookmarkViewModel.swift in Sources */, 3706FC6D293F65D500E42796 /* DaxSpeech.swift in Sources */, 3706FC6E293F65D500E42796 /* DuckPlayerSchemeHandler.swift in Sources */, @@ -9834,6 +9890,7 @@ 3706FE0F293F661700E42796 /* CSVLoginExporterTests.swift in Sources */, 3706FE10293F661700E42796 /* TestNavigationDelegate.swift in Sources */, 3706FE11293F661700E42796 /* URLSuggestedFilenameTests.swift in Sources */, + 4BE344EF2B23786F003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */, B6F56569299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, 3706FE13293F661700E42796 /* ConfigurationStorageTests.swift in Sources */, 3706FE14293F661700E42796 /* DownloadListStoreMock.swift in Sources */, @@ -10217,6 +10274,7 @@ 4B9579652AC7AE700062CA31 /* SecureVaultSorting.swift in Sources */, 4B9579662AC7AE700062CA31 /* PreferencesSidebarModel.swift in Sources */, 4B9579672AC7AE700062CA31 /* DuckPlayerURLExtension.swift in Sources */, + 4B41EDB72B169887001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 4B9579682AC7AE700062CA31 /* BWEncryptionOutput.m in Sources */, 4B9579692AC7AE700062CA31 /* PermissionState.swift in Sources */, 4B95796A2AC7AE700062CA31 /* FeedbackPresenter.swift in Sources */, @@ -10261,6 +10319,7 @@ 4B9579912AC7AE700062CA31 /* NSNotificationName+PasswordManager.swift in Sources */, 4B9579922AC7AE700062CA31 /* RulesCompilationMonitor.swift in Sources */, 4B9579932AC7AE700062CA31 /* FBProtectionTabExtension.swift in Sources */, + 4B41EDB82B169889001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, 4B9579942AC7AE700062CA31 /* CrashReportReader.swift in Sources */, 4B9579952AC7AE700062CA31 /* DataTaskProviding.swift in Sources */, 4B9579962AC7AE700062CA31 /* FeatureFlag.swift in Sources */, @@ -10300,6 +10359,7 @@ 4B9579B62AC7AE700062CA31 /* AtbParser.swift in Sources */, 4B9579B72AC7AE700062CA31 /* PreferencesDuckPlayerView.swift in Sources */, 4B9579B82AC7AE700062CA31 /* AddFolderModalViewController.swift in Sources */, + 4B41EDB62B169883001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, 4B9579B92AC7AE700062CA31 /* BookmarkSidebarTreeController.swift in Sources */, 4B9579BA2AC7AE700062CA31 /* HomePageFavoritesModel.swift in Sources */, 4B9579BB2AC7AE700062CA31 /* SequenceExtensions.swift in Sources */, @@ -10340,6 +10400,7 @@ 4B9579DD2AC7AE700062CA31 /* SpacerNode.swift in Sources */, B62B483C2ADE46FC000DECE5 /* Application.swift in Sources */, 4B9579DF2AC7AE700062CA31 /* SyncManagementDialogViewController.swift in Sources */, + 4B05265F2B1AEFDB0054955A /* VPNMetadataCollector.swift in Sources */, 4B9579E02AC7AE700062CA31 /* BookmarkExtension.swift in Sources */, 4B9579E12AC7AE700062CA31 /* PasswordManagementCreditCardModel.swift in Sources */, 4B9579E22AC7AE700062CA31 /* NSEventExtension.swift in Sources */, @@ -10410,6 +10471,7 @@ 4B957A202AC7AE700062CA31 /* CancellableExtension.swift in Sources */, 4B957A212AC7AE700062CA31 /* PinnedTabsHostingView.swift in Sources */, 4B957A222AC7AE700062CA31 /* FirefoxBookmarksReader.swift in Sources */, + 4B0526622B1D55320054955A /* VPNFeedbackSender.swift in Sources */, 4B957A232AC7AE700062CA31 /* DeviceIdleStateDetector.swift in Sources */, 4B957A242AC7AE700062CA31 /* FlatButton.swift in Sources */, 4B957A252AC7AE700062CA31 /* PinnedTabView.swift in Sources */, @@ -10469,6 +10531,7 @@ 1E2AE4C82ACB216B00684E0A /* HoverTrackingArea.swift in Sources */, 4B957A582AC7AE700062CA31 /* PasswordManagementIdentityItemView.swift in Sources */, 4B957A592AC7AE700062CA31 /* ProgressExtension.swift in Sources */, + 4B44FEF52B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */, 4B957A5A2AC7AE700062CA31 /* CSVParser.swift in Sources */, 4B957A5B2AC7AE700062CA31 /* PixelDataModel.xcdatamodeld in Sources */, 4B957A5C2AC7AE700062CA31 /* PrivacyDashboardWebView.swift in Sources */, @@ -10829,6 +10892,7 @@ 4B957BAF2AC7AE700062CA31 /* TabExtensions.swift in Sources */, 4B957BB02AC7AE700062CA31 /* TabBarViewItem.swift in Sources */, 4B957BB12AC7AE700062CA31 /* NSWindow+Toast.swift in Sources */, + 4B0526652B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */, 4B957BB22AC7AE700062CA31 /* AutoconsentUserScript.swift in Sources */, 4B957BB32AC7AE700062CA31 /* BookmarksExporter.swift in Sources */, 4B957BB42AC7AE700062CA31 /* NetworkProtectionAppEvents.swift in Sources */, @@ -10965,6 +11029,7 @@ B693955126F04BEB0015B914 /* GradientView.swift in Sources */, 37AFCE8527DA2D3900471A10 /* PreferencesSidebar.swift in Sources */, B6C00ED5292FB21E009C73A6 /* HoveredLinkTabExtension.swift in Sources */, + 4B0526612B1D55320054955A /* VPNFeedbackSender.swift in Sources */, AA5C8F5E2590EEE800748EB7 /* NSPointExtension.swift in Sources */, 4BF0E5122AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, AA6EF9AD25066F42004754E6 /* WindowsManager.swift in Sources */, @@ -10984,6 +11049,7 @@ 4B9292D72667124000AD2C21 /* NSPopUpButtonExtension.swift in Sources */, 85D33F1225C82EB3002B91A6 /* ConfigurationManager.swift in Sources */, 31F28C4F28C8EEC500119F70 /* YoutubePlayerUserScript.swift in Sources */, + 4B41EDAE2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, B6A9E48426146AAB0067D1B9 /* PixelParameters.swift in Sources */, AA5FA697275F90C400DCE9C9 /* FaviconImageCache.swift in Sources */, 1430DFF524D0580F00B8978C /* TabBarViewController.swift in Sources */, @@ -11211,6 +11277,7 @@ AAB8203C26B2DE0D00788AC3 /* SuggestionListCharacteristics.swift in Sources */, AAADFD06264AA282001555EA /* TimeIntervalExtension.swift in Sources */, 4B6785472AA8DE68008A5004 /* NetworkProtectionFeatureDisabler.swift in Sources */, + 4B0526642B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */, 4B9292D42667123700AD2C21 /* BookmarkListViewController.swift in Sources */, 4B723E0D26B0006100E14D75 /* SecureVaultLoginImporter.swift in Sources */, B645D8F629FA95440024461F /* WKProcessPoolExtension.swift in Sources */, @@ -11302,6 +11369,7 @@ 569277C129DDCBB500B633EF /* HomePageContinueSetUpModel.swift in Sources */, B68D21CF2ACBC9FC002DA3C2 /* ContentBlockerRulesManagerMock.swift in Sources */, B6A924D92664C72E001A28CA /* WebKitDownloadTask.swift in Sources */, + 4B44FEF32B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */, 4B59023E26B35F3600489384 /* ChromiumLoginReader.swift in Sources */, 85D885B326A5A9DE0077C374 /* NSAlert+PasswordManager.swift in Sources */, 983DFB2528B67036006B7E34 /* UserContentUpdating.swift in Sources */, @@ -11317,6 +11385,7 @@ 4B9292D52667123700AD2C21 /* BookmarkManagementDetailViewController.swift in Sources */, 4B723E1026B0006700E14D75 /* CSVImporter.swift in Sources */, 37A4CEBA282E992F00D75B89 /* StartupPreferences.swift in Sources */, + 4B41EDB12B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 7BFE95542A9DF2930081ABE9 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, AA4BBA3B25C58FA200C4FB0F /* MainMenu.swift in Sources */, 4B8AC93326B3B06300879451 /* EdgeDataImporter.swift in Sources */, @@ -11381,6 +11450,7 @@ 4B9292A226670D2A00AD2C21 /* PseudoFolder.swift in Sources */, AA7E919A2875B39300AB6B62 /* Visit.swift in Sources */, 4BCF15D92ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */, + 4B05265E2B1AE5C70054955A /* VPNMetadataCollector.swift in Sources */, B6DA44022616B28300DD1EC2 /* PixelDataStore.swift in Sources */, 4B9DB0292A983B24000927DB /* WaitlistStorage.swift in Sources */, B6A9E45326142B070067D1B9 /* Pixel.swift in Sources */, @@ -11409,6 +11479,7 @@ 4B9292A326670D2A00AD2C21 /* BookmarkManagedObject.swift in Sources */, 4B723E1326B0007A00E14D75 /* CSVLoginExporter.swift in Sources */, 85C48CCC278D808F00D3263E /* NSAttributedStringExtension.swift in Sources */, + 4B41EDB42B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, AA7EB6E527E7D6DC00036718 /* AnimationView.swift in Sources */, 8562599A269CA0A600EE44BC /* NSRectExtension.swift in Sources */, 4B9DB0472A983B24000927DB /* WaitlistRootView.swift in Sources */, @@ -11691,6 +11762,7 @@ B6A5A2A025B96E8300AA7ADA /* AppStateChangePublisherTests.swift in Sources */, B63ED0E326B3E7FA00A9DAD1 /* CLLocationManagerMock.swift in Sources */, 37CD54BB27F25A4000F1F7B9 /* DownloadsPreferencesTests.swift in Sources */, + 4BE344EE2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */, 4B9DB0562A983B55000927DB /* MockNotificationService.swift in Sources */, 4B02199C25E063DE00ED7DEA /* FireproofDomainsTests.swift in Sources */, AA0F3DB7261A566C0077F2D9 /* SuggestionLoadingMock.swift in Sources */, diff --git a/DuckDuckGo/Application/URLEventHandler.swift b/DuckDuckGo/Application/URLEventHandler.swift index f777ec90cd..a50c2101c4 100644 --- a/DuckDuckGo/Application/URLEventHandler.swift +++ b/DuckDuckGo/Application/URLEventHandler.swift @@ -123,6 +123,8 @@ final class URLEventHandler { } case AppLaunchCommand.showSettings.launchURL: WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .vpn) + case AppLaunchCommand.shareFeedback.launchURL: + WindowControllersManager.shared.showShareFeedbackModal() default: return } diff --git a/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/Contents.json new file mode 100644 index 0000000000..af281775d5 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "VPNFeedbackSent.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/VPNFeedbackSent.pdf b/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/VPNFeedbackSent.pdf new file mode 100644 index 0000000000..976e847ae8 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/VPNFeedbackSent.pdf differ diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index ff2acbfdd4..29e6bf9e93 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -37,7 +37,7 @@ extension UserText { // MARK: - Navigation Bar Status View - static let networkProtectionNavBarStatusViewShareFeedback = NSLocalizedString("network.protection.navbar.status.view.share.feedback", value: "Share Feedback…", comment: "Menu item for 'Share Feedback' in the Network Protection status view that's shown in the navigation bar") + static let networkProtectionNavBarStatusViewShareFeedback = NSLocalizedString("network.protection.navbar.status.view.share.feedback", value: "Send Feedback…", comment: "Menu item for 'Send Feedback' in the Network Protection status view that's shown in the navigation bar") static let networkProtectionNavBarStatusMenuVPNSettings = NSLocalizedString("network.protection.status.menu.vpn.settings", value: "VPN Settings…", comment: "The status menu 'VPN Settings' menu item") // MARK: - System Extension Installation Messages @@ -153,6 +153,34 @@ extension UserText { static let networkProtectionTermsOfServiceSection8Title = NSLocalizedString("network-protection.terms-of-service.section.8.title", value: "We need your feedback.", comment: "Terms of Service title for Network Protection") static let networkProtectionTermsOfServiceSection8List = NSLocalizedString("network-protection.terms-of-service.section.8.list", value: "You may be asked during the beta period to provide feedback about your experience. Doing so is optional and your feedback may be used to improve the service.\n\nIf you have enabled notifications for the DuckDuckGo app, we may use notifications to ask about your experience. You can disable notifications if you do not want to receive them.", comment: "Terms of Service list for Network Protection") + // MARK: - Feedback Form + + static let vpnFeedbackFormTitle = NSLocalizedString("vpn.feedback-form.title", value: "Help Improve the DuckDuckGo VPN", comment: "Title for each screen of the VPN feedback form") + static let vpnFeedbackFormCategorySelect = NSLocalizedString("vpn.feedback-form.category.select-category", value: "Select a category", comment: "Title for the category selection state of the VPN feedback form") + static let vpnFeedbackFormCategoryUnableToInstall = NSLocalizedString("vpn.feedback-form.category.unable-to-install", value: "Unable to install VPN", comment: "Title for the 'unable to install' category of the VPN feedback form") + static let vpnFeedbackFormCategoryFailsToConnect = NSLocalizedString("vpn.feedback-form.category.fails-to-connect", value: "VPN fails to connect", comment: "Title for the 'VPN fails to connect' category of the VPN feedback form") + static let vpnFeedbackFormCategoryTooSlow = NSLocalizedString("vpn.feedback-form.category.too-slow", value: "VPN connection is too slow", comment: "Title for the 'VPN is too slow' category of the VPN feedback form") + static let vpnFeedbackFormCategoryIssuesWithApps = NSLocalizedString("vpn.feedback-form.category.issues-with-apps", value: "VPN causes issues with other apps or websites", comment: "Title for the category 'VPN causes issues with other apps or websites' category of the VPN feedback form") + static let vpnFeedbackFormCategoryLocalDeviceConnectivity = NSLocalizedString("vpn.feedback-form.category.local-device-connectivity", value: "VPN won't let me connect to local device", comment: "Title for the local device connectivity category of the VPN feedback form") + static let vpnFeedbackFormCategoryBrowserCrashOrFreeze = NSLocalizedString("vpn.feedback-form.category.browser-crash-or-freeze", value: "VPN causes browser to crash or freeze", comment: "Title for the browser crash/freeze category of the VPN feedback form") + static let vpnFeedbackFormCategoryFeatureRequest = NSLocalizedString("vpn.feedback-form.category.feature-request", value: "VPN feature request", comment: "Title for the 'VPN feature request' category of the VPN feedback form") + static let vpnFeedbackFormCategoryOther = NSLocalizedString("vpn.feedback-form.category.other", value: "Other VPN feedback", comment: "Title for the 'other VPN feedback' category of the VPN feedback form") + + static let vpnFeedbackFormText1 = NSLocalizedString("vpn.feedback-form.text-1", value: "Please describe what's happening, what you expected to happen, and the steps that led to the issue:", comment: "Text for the body of the VPN feedback form") + static let vpnFeedbackFormText2 = NSLocalizedString("vpn.feedback-form.text-2", value: "In addition to the details entered into this form, your app issue report will contain:", comment: "Text for the body of the VPN feedback form") + static let vpnFeedbackFormText3 = NSLocalizedString("vpn.feedback-form.text-3", value: "• Whether specific DuckDuckGo features are enabled", comment: "Bullet text for the body of the VPN feedback form") + static let vpnFeedbackFormText4 = NSLocalizedString("vpn.feedback-form.text-4", value: "• Aggregate DuckDuckGo app diagnostics", comment: "Bullet text for the body of the VPN feedback form") + static let vpnFeedbackFormText5 = NSLocalizedString("vpn.feedback-form.text-5", value: "By clicking \"Submit\" I agree that DuckDuckGo may use the information in this report for purposes of improving the app's features.", comment: "Text for the body of the VPN feedback form") + + static let vpnFeedbackFormSendingConfirmationTitle = NSLocalizedString("vpn.feedback-form.sending-confirmation.title", value: "Thank you!", comment: "Title for the feedback sent view title of the VPN feedback form") + static let vpnFeedbackFormSendingConfirmationDescription = NSLocalizedString("vpn.feedback-form.sending-confirmation.description", value: "Your feedback will help us improve the\nDuckDuckGo VPN.", comment: "Title for the feedback sent view description of the VPN feedback form") + static let vpnFeedbackFormSendingConfirmationError = NSLocalizedString("vpn.feedback-form.sending-confirmation.error", value: "We couldn't send your feedback right now, please try again.", comment: "Title for the feedback sending error text of the VPN feedback form") + + static let vpnFeedbackFormButtonDone = NSLocalizedString("vpn.feedback-form.button.done", value: "Done", comment: "Title for the Done button of the VPN feedback form") + static let vpnFeedbackFormButtonCancel = NSLocalizedString("vpn.feedback-form.button.cancel", value: "Cancel", comment: "Title for the Cancel button of the VPN feedback form") + static let vpnFeedbackFormButtonSubmit = NSLocalizedString("vpn.feedback-form.button.submit", value: "Submit", comment: "Title for the Submit button of the VPN feedback form") + static let vpnFeedbackFormButtonSubmitting = NSLocalizedString("vpn.feedback-form.button.submitting", value: "Submitting…", comment: "Title for the Submitting state of the VPN feedback form") + } #if DBP @@ -190,6 +218,7 @@ extension UserText { static let dataBrokerProtectionWaitlistButtonEnableNotifications = NSLocalizedString("data-broker-protection.waitlist.button.enable-notifications", value: "Enable Notifications", comment: "Enable Notifications button for Personal Information Removal joined waitlist screen") static let dataBrokerProtectionWaitlistButtonJoinWaitlist = NSLocalizedString("data-broker-protection.waitlist.button.join-waitlist", value: "Join the Waitlist", comment: "Join Waitlist button for Personal Information Removal join waitlist screen") static let dataBrokerProtectionWaitlistButtonAgreeAndContinue = NSLocalizedString("data-broker-protection.waitlist.button.agree-and-continue", value: "Agree and Continue", comment: "Agree and Continue button for Personal Information Removal join waitlist screen") + } #endif diff --git a/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift b/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift new file mode 100644 index 0000000000..2522593b97 --- /dev/null +++ b/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift @@ -0,0 +1,53 @@ +// +// FocusableTextEditor.swift +// +// Copyright © 2023 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 SwiftUI + +@available(macOS 12, *) +struct FocusableTextEditor: View { + + @Binding var text: String + @FocusState var isFocused: Bool + + let cornerRadius: CGFloat = 8.0 + let borderWidth: CGFloat = 0.4 + var characterLimit: Int = 10000 + + var body: some View { + TextEditor(text: $text) + .frame(height: 150.0) + .font(.body) + .foregroundColor(.primary) + .focused($isFocused) + .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .onChange(of: text) { + text = String($0.prefix(characterLimit)) + } + .background( + ZStack { + RoundedRectangle(cornerRadius: cornerRadius).stroke(Color.accentColor.opacity(0.5), lineWidth: 4).opacity(isFocused ? 1 : 0).scaleEffect(isFocused ? 1 : 1.04) + .animation(isFocused ? .easeIn(duration: 0.2) : .easeOut(duration: 0.0), value: isFocused) + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color(NSColor.textEditorBorderColor), lineWidth: borderWidth) + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color(NSColor.textEditorBackgroundColor)) + } + ) + } +} diff --git a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/AppLauncher.swift b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/AppLauncher.swift index 2f072731dd..627ff52d49 100644 --- a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/AppLauncher.swift +++ b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/AppLauncher.swift @@ -83,7 +83,7 @@ extension AppLaunchCommand { case .justOpen: return "networkprotection://just-open" case .shareFeedback: - return "https://form.asana.com/?k=_wNLt6YcT5ILpQjDuW0Mxw&d=137249556945" + return "networkprotection://share-feedback" case .showStatus: return "networkprotection://show-status" case .showSettings: diff --git a/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift b/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift index 1504c0160b..65b41fc85a 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift +++ b/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift @@ -470,10 +470,10 @@ private struct NotesView: View { if model.isEditing || model.isNew { #if APPSTORE - FocusableTextEditor() + FocusableTextEditor(text: $model.notes) #else if #available(macOS 12, *) { - FocusableTextEditor() + FocusableTextEditor(text: $model.notes) } else { TextEditor(text: $model.notes) .frame(height: 197.0) @@ -509,41 +509,6 @@ private struct NotesView: View { } -@available(macOS 12, *) -struct FocusableTextEditor: View { - - @EnvironmentObject var model: PasswordManagementLoginModel - @FocusState var isFocused: Bool - - let cornerRadius: CGFloat = 8.0 - let borderWidth: CGFloat = 0.4 - let characterLimit: Int = 10000 - - var body: some View { - TextEditor(text: $model.notes) - .frame(height: 197.0) - .font(.body) - .foregroundColor(.primary) - .focused($isFocused) - .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, - style: .continuous)) - .onChange(of: model.notes) { - model.notes = String($0.prefix(characterLimit)) - } - .background( - ZStack { - RoundedRectangle(cornerRadius: cornerRadius).stroke(Color.accentColor.opacity(0.5), lineWidth: 4).opacity(isFocused ? 1 : 0).scaleEffect(isFocused ? 1 : 1.04) - .animation(isFocused ? .easeIn(duration: 0.2) : .easeOut(duration: 0.0), value: isFocused) - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color(NSColor.textEditorBorderColor), lineWidth: borderWidth) - RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color(NSColor.textEditorBackgroundColor)) - } - ) - } -} - private struct DatesView: View { @EnvironmentObject var model: PasswordManagementLoginModel diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index cfe5009e09..823c666b6f 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -159,6 +159,9 @@ extension Pixel { case dashboardProtectionAllowlistAdd(triggerOrigin: String?) case dashboardProtectionAllowlistRemove(triggerOrigin: String?) + // VPN + case vpnBreakageReport(category: String, description: String, metadata: String) + // Network Protection Waitlist case networkProtectionWaitlistUserActive case networkProtectionWaitlistEntryPointMenuItemDisplayed @@ -473,6 +476,9 @@ extension Pixel.Event { case .serpDay21to27: return "m.mac.search-day-21-27.initial" + case .vpnBreakageReport: + return "m_mac_vpn_breakage_report" + case .networkProtectionWaitlistUserActive: return "m_mac_netp_waitlist_user_active" case .networkProtectionWaitlistEntryPointMenuItemDisplayed: diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index 55f0f693ff..6fe2a8d67f 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -79,6 +79,13 @@ extension Pixel.Event { guard let trigger = triggerOrigin else { return nil } return [PixelKit.Parameters.dashboardTriggerOrigin: trigger] + case .vpnBreakageReport(let category, let description, let metadata): + return [ + PixelKit.Parameters.vpnBreakageCategory: category, + PixelKit.Parameters.vpnBreakageDescription: description, + PixelKit.Parameters.vpnBreakageMetadata: metadata + ] + // Don't use default to force new items to be thought about case .crash, .brokenSiteReport, diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift new file mode 100644 index 0000000000..34adf335df --- /dev/null +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift @@ -0,0 +1,65 @@ +// +// VPNFeedbackCategory.swift +// +// Copyright © 2023 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 + +#if NETWORK_PROTECTION + +enum VPNFeedbackCategory: String, CaseIterable { + case landingPage + case unableToInstall + case failsToConnect + case tooSlow + case issueWithAppOrWebsite + case cantConnectToLocalDevice + case appCrashesOrFreezes + case featureRequest + case somethingElse + + var isFeedbackCategory: Bool { + switch self { + case .landingPage: + return false + case .unableToInstall, + .failsToConnect, + .tooSlow, + .issueWithAppOrWebsite, + .cantConnectToLocalDevice, + .appCrashesOrFreezes, + .featureRequest, + .somethingElse: + return true + } + } + + var displayName: String { + switch self { + case .landingPage: return UserText.vpnFeedbackFormCategorySelect + case .unableToInstall: return UserText.vpnFeedbackFormCategoryUnableToInstall + case .failsToConnect: return UserText.vpnFeedbackFormCategoryFailsToConnect + case .tooSlow: return UserText.vpnFeedbackFormCategoryTooSlow + case .issueWithAppOrWebsite: return UserText.vpnFeedbackFormCategoryIssuesWithApps + case .cantConnectToLocalDevice: return UserText.vpnFeedbackFormCategoryLocalDeviceConnectivity + case .appCrashesOrFreezes: return UserText.vpnFeedbackFormCategoryBrowserCrashOrFreeze + case .featureRequest: return UserText.vpnFeedbackFormCategoryFeatureRequest + case .somethingElse: return UserText.vpnFeedbackFormCategoryOther + } + } +} + +#endif diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift new file mode 100644 index 0000000000..9f909044eb --- /dev/null +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -0,0 +1,218 @@ +// +// VPNFeedbackFormView.swift +// +// Copyright © 2023 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 SwiftUI + +#if NETWORK_PROTECTION + +struct VPNFeedbackFormView: View { + + @EnvironmentObject var viewModel: VPNFeedbackFormViewModel + + var body: some View { + VStack(spacing: 0) { + Group { + Text(UserText.vpnFeedbackFormTitle) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.secondary) + } + .frame(height: 70) + .frame(maxWidth: .infinity) + .background(Color.secondary.opacity(0.1)) + + Divider() + + switch viewModel.viewState { + case .feedbackPending, .feedbackSending, .feedbackSendingFailed: + VPNFeedbackFormBodyView() + .padding([.top, .leading, .trailing], 20) + + if viewModel.viewState == .feedbackSendingFailed { + Text(UserText.vpnFeedbackFormSendingConfirmationError) + .foregroundColor(.red) + .padding(.top, 15) + } + case .feedbackSent: + VPNFeedbackFormSentView() + .padding([.top, .leading, .trailing], 20) + } + + Spacer(minLength: 0) + + VPNFeedbackFormButtons() + .padding(20) + } + } + +} + +private struct VPNFeedbackFormBodyView: View { + + @EnvironmentObject var viewModel: VPNFeedbackFormViewModel + + var body: some View { + Group { + Picker(selection: $viewModel.selectedFeedbackCategory, content: { + ForEach(VPNFeedbackCategory.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + }, label: {}) + .controlSize(.large) + .padding(.bottom, 0) + + switch viewModel.selectedFeedbackCategory { + case .landingPage: + Spacer() + .frame(height: 50) + case .unableToInstall, + .failsToConnect, + .tooSlow, + .issueWithAppOrWebsite, + .cantConnectToLocalDevice, + .appCrashesOrFreezes, + .featureRequest, + .somethingElse: + VPNFeedbackFormIssueDescriptionForm() + } + } + } + +} + +private struct VPNFeedbackFormIssueDescriptionForm: View { + + @EnvironmentObject var viewModel: VPNFeedbackFormViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(UserText.vpnFeedbackFormText1) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + textEditor() + + Text(UserText.vpnFeedbackFormText2) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + + VStack(alignment: .leading) { + Text(UserText.vpnFeedbackFormText3) + .foregroundColor(.secondary) + Text(UserText.vpnFeedbackFormText4) + .foregroundColor(.secondary) + } + + Text(UserText.vpnFeedbackFormText5) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + func textEditor() -> some View { +#if APPSTORE + FocusableTextEditor(text: $model.notes) +#else + if #available(macOS 12, *) { + FocusableTextEditor(text: $viewModel.feedbackFormText, characterLimit: 1000) + } else { + TextEditor(text: $viewModel.feedbackFormText) + .frame(height: 197.0) + .font(.body) + .foregroundColor(.primary) + .onChange(of: viewModel.feedbackFormText) { + viewModel.feedbackFormText = String($0.prefix(1000)) + } + .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) + .clipShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous)) + .background( + ZStack { + RoundedRectangle(cornerRadius: 8.0) + .stroke(Color(NSColor.textEditorBorderColor), lineWidth: 0.4) + RoundedRectangle(cornerRadius: 8.0) + .fill(Color(NSColor.textEditorBackgroundColor)) + } + ) + } +#endif + } + +} + +private struct VPNFeedbackFormSentView: View { + + var body: some View { + VStack(spacing: 0) { + Image("VPNFeedbackSent") + .padding(.top, 20) + + Text(UserText.vpnFeedbackFormSendingConfirmationTitle) + .font(.system(size: 18, weight: .medium)) + .padding(.top, 30) + + Text(UserText.vpnFeedbackFormSendingConfirmationDescription) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 10) + } + } + +} + +private struct VPNFeedbackFormButtons: View { + + @EnvironmentObject var viewModel: VPNFeedbackFormViewModel + + var body: some View { + HStack { + if viewModel.viewState == .feedbackSent { + button(text: UserText.vpnFeedbackFormButtonDone, action: .cancel) + .keyboardShortcut(.defaultAction) + } else { + button(text: UserText.vpnFeedbackFormButtonCancel, action: .cancel) + button(text: viewModel.viewState == .feedbackSending ? UserText.vpnFeedbackFormButtonSubmitting : UserText.vpnFeedbackFormButtonSubmit, action: .submit) + .keyboardShortcut(.defaultAction) + .disabled(!viewModel.submitButtonEnabled) + } + } + } + + @ViewBuilder + func button(text: String, action: VPNFeedbackFormViewModel.ViewAction) -> some View { + Button(action: { + Task { + await viewModel.process(action: action) + } + }, label: { + Text(text) + .frame(maxWidth: .infinity) + }) + .controlSize(.large) + .frame(maxWidth: .infinity) + } + +} + +#endif diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift new file mode 100644 index 0000000000..4311945264 --- /dev/null +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift @@ -0,0 +1,124 @@ +// +// VPNFeedbackFormViewController.swift +// +// Copyright © 2023 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. +// + +#if NETWORK_PROTECTION + +import Foundation +import AppKit +import SwiftUI +import Combine + +final class VPNFeedbackFormViewController: NSViewController { + + // Using a dynamic height in the form was causing layout problems and couldn't be completed in time for the release that needed this form. + // As a temporary measure, the heights of each form state are hardcoded. + // This should be cleaned up later, and eventually use the `sizingOptions` property of NSHostingController. + enum Constants { + static let landingPageHeight = 260.0 + static let feedbackFormHeight = 535.0 + static let feedbackSentHeight = 350.0 + static let feedbackErrorHeight = 560.0 + } + + private let defaultSize = CGSize(width: 480, height: Constants.landingPageHeight) + private let viewModel: VPNFeedbackFormViewModel + + private var heightConstraint: NSLayoutConstraint? + private var cancellables = Set() + + init() { + self.viewModel = VPNFeedbackFormViewModel() + super.init(nibName: nil, bundle: nil) + self.viewModel.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = NSView(frame: NSRect(origin: CGPoint.zero, size: defaultSize)) + } + + override func viewDidLoad() { + super.viewDidLoad() + + let feedbackFormView = VPNFeedbackFormView() + let hostingView = NSHostingView(rootView: feedbackFormView.environmentObject(self.viewModel)) + hostingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingView) + + let heightConstraint = hostingView.heightAnchor.constraint(equalToConstant: defaultSize.height) + self.heightConstraint = heightConstraint + + NSLayoutConstraint.activate([ + heightConstraint, + hostingView.widthAnchor.constraint(equalToConstant: defaultSize.width), + hostingView.topAnchor.constraint(equalTo: view.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingView.leftAnchor.constraint(equalTo: view.leftAnchor), + hostingView.rightAnchor.constraint(equalTo: view.rightAnchor) + ]) + + subscribeToViewModelChanges() + } + + func subscribeToViewModelChanges() { + viewModel.$viewState + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateViewHeight() + } + .store(in: &cancellables) + + viewModel.$selectedFeedbackCategory + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateViewHeight() + } + .store(in: &cancellables) + } + + private func updateViewHeight() { + switch viewModel.viewState { + case .feedbackPending: + if viewModel.selectedFeedbackCategory == .landingPage { + heightConstraint?.constant = Constants.landingPageHeight + } else { + heightConstraint?.constant = Constants.feedbackFormHeight + } + case .feedbackSending: + heightConstraint?.constant = Constants.feedbackFormHeight + case .feedbackSent: + heightConstraint?.constant = Constants.feedbackSentHeight + case .feedbackSendingFailed: + heightConstraint?.constant = Constants.feedbackErrorHeight + } + } + +} + +extension VPNFeedbackFormViewController: VPNFeedbackFormViewModelDelegate { + + func vpnFeedbackViewModelDismissedView(_ viewModel: VPNFeedbackFormViewModel) { + dismiss() + } + +} + +#endif diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift new file mode 100644 index 0000000000..76282a51af --- /dev/null +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift @@ -0,0 +1,104 @@ +// +// VPNFeedbackFormViewModel.swift +// +// Copyright © 2023 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. +// + +#if NETWORK_PROTECTION + +import Foundation +import Combine +import SwiftUI + +protocol VPNFeedbackFormViewModelDelegate: AnyObject { + func vpnFeedbackViewModelDismissedView(_ viewModel: VPNFeedbackFormViewModel) +} + +final class VPNFeedbackFormViewModel: ObservableObject { + + enum ViewState { + case feedbackPending + case feedbackSending + case feedbackSendingFailed + case feedbackSent + + var canSubmit: Bool { + switch self { + case .feedbackPending: return true + case .feedbackSending: return false + case .feedbackSendingFailed: return true + case .feedbackSent: return false + } + } + } + + enum ViewAction { + case cancel + case submit + } + + @Published var viewState: ViewState { + didSet { + updateSubmitButtonStatus() + } + } + + @Published var feedbackFormText: String = "" { + didSet { + updateSubmitButtonStatus() + } + } + + @Published private(set) var submitButtonEnabled: Bool = false + @Published var selectedFeedbackCategory: VPNFeedbackCategory + + weak var delegate: VPNFeedbackFormViewModelDelegate? + + private let metadataCollector: VPNMetadataCollector + private let feedbackSender: VPNFeedbackSender + + init(metadataCollector: VPNMetadataCollector = DefaultVPNMetadataCollector(), feedbackSender: VPNFeedbackSender = DefaultVPNFeedbackSender()) { + self.viewState = .feedbackPending + self.selectedFeedbackCategory = .landingPage + + self.metadataCollector = metadataCollector + self.feedbackSender = feedbackSender + } + + @MainActor + func process(action: ViewAction) async { + switch action { + case .cancel: + delegate?.vpnFeedbackViewModelDismissedView(self) + case .submit: + self.viewState = .feedbackSending + + do { + let metadata = await self.metadataCollector.collectMetadata() + try await self.feedbackSender.send(metadata: metadata, category: selectedFeedbackCategory, userText: feedbackFormText) + self.viewState = .feedbackSent + } catch { + self.viewState = .feedbackSendingFailed + } + } + } + + private func updateSubmitButtonStatus() { + self.submitButtonEnabled = viewState.canSubmit && !feedbackFormText.isEmpty + } + +} + +#endif diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift new file mode 100644 index 0000000000..dcdbfd0d96 --- /dev/null +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift @@ -0,0 +1,46 @@ +// +// VPNFeedbackSender.swift +// +// Copyright © 2023 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. +// + +#if NETWORK_PROTECTION + +import Foundation + +protocol VPNFeedbackSender { + func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws +} + +struct DefaultVPNFeedbackSender: VPNFeedbackSender { + + func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws { + let encodedUserText = userText.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? userText + let pixelEvent = Pixel.Event.vpnBreakageReport(category: category.rawValue, description: encodedUserText, metadata: metadata.toBase64()) + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Pixel.fire(pixelEvent) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + +} + +#endif diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift new file mode 100644 index 0000000000..fc320fd971 --- /dev/null +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -0,0 +1,257 @@ +// +// VPNMetadataCollector.swift +// +// Copyright © 2023 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. +// + +#if NETWORK_PROTECTION + +import Foundation +import Common +import LoginItems +import NetworkProtection +import NetworkExtension +import NetworkProtectionIPC +import NetworkProtectionUI + +struct VPNMetadata: Encodable { + + struct AppInfo: Encodable { + let appVersion: String + let lastVersionRun: String + let isInternalUser: Bool + } + + struct DeviceInfo: Encodable { + let osVersion: String + let buildFlavor: String + let lowPowerModeEnabled: Bool + } + + struct NetworkInfo: Encodable { + let currentPath: String + } + + struct VPNState: Encodable { + let onboardingState: String + let connectionState: String + let lastErrorMessage: String + let connectedServer: String + let connectedServerIP: String + } + + struct VPNSettingsState: Encodable { + let connectOnLoginEnabled: Bool + let includeAllNetworksEnabled: Bool + let enforceRoutesEnabled: Bool + let excludeLocalNetworksEnabled: Bool + let notifyStatusChangesEnabled: Bool + let showInMenuBarEnabled: Bool + let selectedServer: String + let selectedEnvironment: String + } + + struct LoginItemState: Encodable { + let vpnMenuState: String + +#if NETP_SYSTEM_EXTENSION + let notificationsAgentState: String +#endif + } + + let appInfo: AppInfo + let deviceInfo: DeviceInfo + let networkInfo: NetworkInfo + let vpnState: VPNState + let vpnSettingsState: VPNSettingsState + let loginItemState: LoginItemState + + func toPrettyPrintedJSON() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + guard let encodedMetadata = try? encoder.encode(self) else { + assertionFailure("Failed to encode metadata") + return nil + } + + return String(data: encodedMetadata, encoding: .utf8) + } + + func toBase64() -> String { + let encoder = JSONEncoder() + + do { + let encodedMetadata = try encoder.encode(self) + return encodedMetadata.base64EncodedString() + } catch { + return "Failed to encode metadata to JSON, error message: \(error.localizedDescription)" + } + } + +} + +protocol VPNMetadataCollector { + func collectMetadata() async -> VPNMetadata +} + +final class DefaultVPNMetadataCollector: VPNMetadataCollector { + + private let statusReporter: NetworkProtectionStatusReporter + + init() { + let machServiceName = Bundle.main.vpnMenuAgentBundleId + let ipcClient = TunnelControllerIPCClient(machServiceName: machServiceName) + ipcClient.register() + + self.statusReporter = DefaultNetworkProtectionStatusReporter( + statusObserver: ipcClient.connectionStatusObserver, + serverInfoObserver: ipcClient.serverInfoObserver, + connectionErrorObserver: ipcClient.connectionErrorObserver, + connectivityIssuesObserver: ConnectivityIssueObserverThroughDistributedNotifications(), + controllerErrorMessageObserver: ControllerErrorMesssageObserverThroughDistributedNotifications() + ) + + // Force refresh just in case. A refresh is requested when the IPC client is created, but distributed notifications don't guarantee delivery + // so we'll play it safe and add one more attempt. + self.statusReporter.forceRefresh() + } + + @MainActor + func collectMetadata() async -> VPNMetadata { + let appInfoMetadata = collectAppInfoMetadata() + let deviceInfoMetadata = collectDeviceInfoMetadata() + let networkInfoMetadata = await collectNetworkInformation() + let vpnState = await collectVPNState() + let vpnSettingsState = collectVPNSettingsState() + let loginItemState = collectLoginItemState() + + return VPNMetadata( + appInfo: appInfoMetadata, + deviceInfo: deviceInfoMetadata, + networkInfo: networkInfoMetadata, + vpnState: vpnState, + vpnSettingsState: vpnSettingsState, + loginItemState: loginItemState + ) + } + + // MARK: - Metadata Collection + + private func collectAppInfoMetadata() -> VPNMetadata.AppInfo { + let appVersion = AppVersion.shared.versionNumber + let versionStore = NetworkProtectionLastVersionRunStore() + let isInternalUser = NSApp.delegateTyped.internalUserDecider.isInternalUser + + return .init(appVersion: appVersion, lastVersionRun: versionStore.lastVersionRun ?? "Unknown", isInternalUser: isInternalUser) + } + + private func collectDeviceInfoMetadata() -> VPNMetadata.DeviceInfo { +#if APPSTORE + let buildFlavor: String = "appstore" +#else + let buildFlavor: String = "dmg" +#endif + + let osVersion = AppVersion.shared.osVersion + let lowPowerModeEnabled: Bool + + if #available(macOS 12.0, *) { + lowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + } else { + lowPowerModeEnabled = false + } + + return .init(osVersion: osVersion, buildFlavor: buildFlavor, lowPowerModeEnabled: lowPowerModeEnabled) + } + + func collectNetworkInformation() async -> VPNMetadata.NetworkInfo { + let monitor = NWPathMonitor() + monitor.start(queue: DispatchQueue(label: "VPNMetadataCollector.NWPathMonitor.paths")) + + var path: Network.NWPath? + let startTime = CFAbsoluteTimeGetCurrent() + + while true { + if !monitor.currentPath.availableInterfaces.isEmpty { + path = monitor.currentPath + monitor.cancel() + return .init(currentPath: path.debugDescription) + } + + // Wait up to 3 seconds to fetch the path. + let currentExecutionTime = CFAbsoluteTimeGetCurrent() - startTime + if currentExecutionTime >= 3.0 { + return .init(currentPath: "Timed out fetching path") + } + } + } + + @MainActor + func collectVPNState() async -> VPNMetadata.VPNState { + let onboardingState: String + + switch UserDefaults.netP.networkProtectionOnboardingStatus { + case .completed: + onboardingState = "complete" + case .isOnboarding(let step): + switch step { + case .userNeedsToAllowExtension: + onboardingState = "pending-extension-approval" + case .userNeedsToAllowVPNConfiguration: + onboardingState = "pending-vpn-approval" + } + } + + let connectionState = String(describing: statusReporter.statusObserver.recentValue) + let lastErrorMessage = statusReporter.connectionErrorObserver.recentValue ?? "none" + let connectedServer = statusReporter.serverInfoObserver.recentValue.serverLocation ?? "none" + let connectedServerIP = statusReporter.serverInfoObserver.recentValue.serverAddress ?? "none" + return .init(onboardingState: onboardingState, + connectionState: connectionState, + lastErrorMessage: lastErrorMessage, + connectedServer: connectedServer, + connectedServerIP: connectedServerIP) + } + + func collectLoginItemState() -> VPNMetadata.LoginItemState { + let vpnMenuState = String(describing: LoginItem.vpnMenu.status) + +#if NETP_SYSTEM_EXTENSION + let notificationsAgentState = String(describing: LoginItem.notificationsAgent.status) + return .init(vpnMenuState: vpnMenuState, notificationsAgentState: notificationsAgentState) +#else + return .init(vpnMenuState: vpnMenuState) +#endif + } + + func collectVPNSettingsState() -> VPNMetadata.VPNSettingsState { + let settings = VPNSettings(defaults: .netP) + + return .init( + connectOnLoginEnabled: settings.connectOnLogin, + includeAllNetworksEnabled: settings.includeAllNetworks, + enforceRoutesEnabled: settings.enforceRoutes, + excludeLocalNetworksEnabled: settings.excludeLocalNetworks, + notifyStatusChangesEnabled: settings.notifyStatusChanges, + showInMenuBarEnabled: settings.showInMenuBar, + selectedServer: settings.selectedServer.stringValue ?? "automatic", + selectedEnvironment: settings.selectedEnvironment.rawValue + ) + } + +} + +#endif diff --git a/DuckDuckGo/Windows/View/WindowControllersManager.swift b/DuckDuckGo/Windows/View/WindowControllersManager.swift index 5a2beeadfe..4287d002ef 100644 --- a/DuckDuckGo/Windows/View/WindowControllersManager.swift +++ b/DuckDuckGo/Windows/View/WindowControllersManager.swift @@ -219,6 +219,19 @@ extension WindowControllersManager { windowController.mainViewController.navigationBarViewController.showNetworkProtectionStatus() } + + func showShareFeedbackModal() { + let feedbackFormViewController = VPNFeedbackFormViewController() + let feedbackFormWindowController = feedbackFormViewController.wrappedInWindowController() + + guard let feedbackFormWindow = feedbackFormWindowController.window, + let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController else { + assertionFailure("Failed to present native VPN feedback form") + return + } + + parentWindowController.window?.beginSheet(feedbackFormWindow) + } #endif } diff --git a/DuckDuckGoDBPBackgroundAgent/UserText.swift b/DuckDuckGoDBPBackgroundAgent/UserText.swift index 227f211608..670a372511 100644 --- a/DuckDuckGoDBPBackgroundAgent/UserText.swift +++ b/DuckDuckGoDBPBackgroundAgent/UserText.swift @@ -21,6 +21,6 @@ import Foundation final class UserText { // MARK: - Status Menu - static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Share Feedback...", comment: "The status menu 'Share Feedback' menu item") + static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Send Feedback...", comment: "The status menu 'Send Feedback' menu item") static let networkProtectionStatusMenuOpenDuckDuckGo = NSLocalizedString("network.protection.status.menu.open.duckduckgo", value: "Open DuckDuckGo...", comment: "The status menu 'Open DuckDuckGo' menu item") } diff --git a/DuckDuckGoVPN/UserText.swift b/DuckDuckGoVPN/UserText.swift index 1513654233..cbbb05f8b2 100644 --- a/DuckDuckGoVPN/UserText.swift +++ b/DuckDuckGoVPN/UserText.swift @@ -23,5 +23,5 @@ final class UserText { static let networkProtectionStatusMenuVPNSettings = NSLocalizedString("network.protection.status.menu.vpn.settings", value: "VPN Settings…", comment: "The status menu 'VPN Settings' menu item") static let networkProtectionStatusMenuOpenDuckDuckGo = NSLocalizedString("network.protection.status.menu.vpn.open-duckduckgo", value: "Open DuckDuckGo…", comment: "The status menu 'Open DuckDuckGo' menu item") - static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Share Feedback…", comment: "The status menu 'Share Feedback' menu item") + static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Send Feedback…", comment: "The status menu 'Send Feedback' menu item") } diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index 5eb9a976e9..93200ad388 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -41,6 +41,9 @@ let nonSandboxedExtraInputFiles: Set = [ .init("DataBrokerProtectionFeatureVisibility.swift", .source), .init("DataBrokerProtectionFeatureDisabler.swift", .source), .init("DataBrokerProtectionAppEvents.swift", .source), + .init("VPNMetadataCollector.swift", .source), + .init("VPNFeedbackCategory.swift", .source), + .init("VPNFeedbackSender.swift", .source), .init("DuckDuckGoDBPBackgroundAgent.app", .unknown) ] diff --git a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift index 493df61c2d..8f2ca1bd8d 100644 --- a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift +++ b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift @@ -57,6 +57,11 @@ public extension PixelKit { // Dashboard public static let dashboardTriggerOrigin = "trigger_origin" + + // VPN + public static let vpnBreakageCategory = "breakageCategory" + public static let vpnBreakageDescription = "breakageDescription" + public static let vpnBreakageMetadata = "breakageMetadata" } enum Values { diff --git a/LocalPackages/SystemExtensionManager/Sources/SystemExtensionManager/SystemExtensionManager.swift b/LocalPackages/SystemExtensionManager/Sources/SystemExtensionManager/SystemExtensionManager.swift index 64d7ca5a62..956b79e3af 100644 --- a/LocalPackages/SystemExtensionManager/Sources/SystemExtensionManager/SystemExtensionManager.swift +++ b/LocalPackages/SystemExtensionManager/Sources/SystemExtensionManager/SystemExtensionManager.swift @@ -19,7 +19,7 @@ import Foundation import Cocoa import Combine -@preconcurrency import SystemExtensions +import SystemExtensions public enum SystemExtensionRequestError: Error { case unknownRequestResult diff --git a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift new file mode 100644 index 0000000000..e325cf4516 --- /dev/null +++ b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift @@ -0,0 +1,163 @@ +// +// VPNFeedbackFormViewModelTests.swift +// +// Copyright © 2023 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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +#if NETWORK_PROTECTION + +final class VPNFeedbackFormViewModelTests: XCTestCase { + + func testWhenCreatingViewModel_ThenInitialStateIsFeedbackPending() throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) + + XCTAssertEqual(viewModel.viewState, .feedbackPending) + XCTAssertEqual(viewModel.selectedFeedbackCategory, .landingPage) + } + + func testWhenSendingFeedbackSucceeds_ThenFeedbackIsSent() async throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) + let text = "Some feedback report text" + viewModel.selectedFeedbackCategory = .unableToInstall + viewModel.feedbackFormText = text + + XCTAssertFalse(sender.sentMetadata) + await viewModel.process(action: .submit) + XCTAssertTrue(sender.sentMetadata) + XCTAssertEqual(sender.receivedData!.1, .unableToInstall) + XCTAssertEqual(sender.receivedData!.2, text) + } + + func testWhenSendingFeedbackFails_ThenFeedbackIsNotSent() async throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) + let text = "Some feedback report text" + viewModel.selectedFeedbackCategory = .unableToInstall + viewModel.feedbackFormText = text + sender.throwErrorWhenSending = true + + XCTAssertFalse(sender.sentMetadata) + await viewModel.process(action: .submit) + XCTAssertFalse(sender.sentMetadata) + XCTAssertEqual(viewModel.viewState, .feedbackSendingFailed) + } + + func testWhenCancelActionIsReceived_ThenViewModelSendsCancelActionToDelegate() async throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) + viewModel.delegate = delegate + + XCTAssertFalse(delegate.receivedDismissedViewCallback) + await viewModel.process(action: .cancel) + XCTAssertTrue(delegate.receivedDismissedViewCallback) + } + +} + +// MARK: - Mocks + +private class MockVPNMetadataCollector: VPNMetadataCollector { + + var collectedMetadata: Bool = false + + func collectMetadata() async -> VPNMetadata { + self.collectedMetadata = true + + let appInfo = VPNMetadata.AppInfo(appVersion: "1.2.3", lastVersionRun: "1.2.3", isInternalUser: false) + let deviceInfo = VPNMetadata.DeviceInfo(osVersion: "14.0.0", buildFlavor: "dmg", lowPowerModeEnabled: false) + let networkInfo = VPNMetadata.NetworkInfo(currentPath: "path") + + let vpnState = VPNMetadata.VPNState( + onboardingState: "onboarded", + connectionState: "connected", + lastErrorMessage: "none", + connectedServer: "Paoli, PA", + connectedServerIP: "123.123.123.123" + ) + + let vpnSettingsState = VPNMetadata.VPNSettingsState( + connectOnLoginEnabled: true, + includeAllNetworksEnabled: true, + enforceRoutesEnabled: true, + excludeLocalNetworksEnabled: true, + notifyStatusChangesEnabled: true, + showInMenuBarEnabled: true, + selectedServer: "server", + selectedEnvironment: "production" + ) + + let loginItemState = VPNMetadata.LoginItemState( + vpnMenuState: "enabled", + notificationsAgentState: "enabled" + ) + + return VPNMetadata( + appInfo: appInfo, + deviceInfo: deviceInfo, + networkInfo: networkInfo, + vpnState: vpnState, + vpnSettingsState: vpnSettingsState, + loginItemState: loginItemState + ) + } + +} + +private class MockVPNFeedbackSender: VPNFeedbackSender { + + var throwErrorWhenSending: Bool = false + var sentMetadata: Bool = false + + var receivedData: (VPNMetadata, VPNFeedbackCategory, String)? + + enum SomeError: Error { + case error + } + + func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws { + if throwErrorWhenSending { + throw SomeError.error + } + + self.sentMetadata = true + self.receivedData = (metadata, category, userText) + } + +} + +private class MockVPNFeedbackFormViewModelDelegate: VPNFeedbackFormViewModelDelegate { + + var receivedDismissedViewCallback: Bool = false + + func vpnFeedbackViewModelDismissedView(_ viewModel: VPNFeedbackFormViewModel) { + receivedDismissedViewCallback = true + } + +} + +#endif