From ffff49ab19349fc424c26c63b42a26312b0de493 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sun, 18 Feb 2024 14:17:58 +0600 Subject: [PATCH] Custom html error page implementation (#2136) Task/Issue URL: https://app.asana.com/0/72649045549333/1203487090719123/f Tech Design URL: https://app.asana.com/0/481882893211075/1203487090719150/f BSK PR: https://github.com/duckduckgo/BrowserServicesKit/pull/644 Content-Scope-Scripts PR: https://github.com/duckduckgo/content-scope-scripts/pull/906 --- Configuration/Tests/IntegrationTests.xcconfig | 2 + DuckDuckGo.xcodeproj/project.pbxproj | 100 +- .../Alert-Medium-Multicolor-16.pdf | Bin 0 -> 2374 bytes .../Contents.json | 12 + .../DefaultFavicon.imageset/Contents.json | 21 - .../DefaultFavicon.imageset/default-fav.png | Bin 180 -> 0 bytes .../Contents.json | 12 + .../Globe-Multicolor-16.pdf | Bin 0 -> 4115 bytes DuckDuckGo/Bridging.h | 1 + .../Extensions/NSObject+performSelector.h | 27 + .../Extensions/NSObject+performSelector.m | 52 + .../Common/Extensions/StringExtension.swift | 39 +- .../Common/Extensions/URLExtension.swift | 2 + .../WKBackForwardListItemExtension.swift | 35 + .../Extensions/WKWebViewExtension.swift | 17 + DuckDuckGo/Common/Localizables/UserText.swift | 8 +- DuckDuckGo/Common/View/AppKit/ColorView.swift | 1 + DuckDuckGo/DataExport/BookmarksExporter.swift | 8 +- .../ErrorPage/ErrorPageHTMLTemplate.swift | 45 + DuckDuckGo/Localizable.xcstrings | 52 +- .../MainWindow/MainViewController.swift | 2 +- .../AddressBarButtonsViewController.swift | 49 +- .../View/AddressBarViewController.swift | 10 +- .../View/NavigationButtonMenuDelegate.swift | 73 +- .../ViewModel/BackForwardListItem.swift | 40 +- ...ift => BackForwardListItemViewModel.swift} | 50 +- DuckDuckGo/Sharing/SharingMenu.swift | 1 + DuckDuckGo/Tab/Model/Tab+Navigation.swift | 7 +- DuckDuckGo/Tab/Model/Tab.swift | 185 +++- ...NonexistentDomainNavigationResponder.swift | 6 +- .../Tab/TabExtensions/TabExtensions.swift | 3 +- .../Tab/TabLazyLoader/LazyLoadable.swift | 8 +- .../Tab/View/Base.lproj/BrowserTab.storyboard | 121 --- .../Tab/View/BrowserTabViewController.swift | 102 +- DuckDuckGo/Tab/ViewModel/TabViewModel.swift | 68 +- .../TabPreview/TabPreviewViewController.swift | 2 +- .../YoutubePlayer/DuckURLSchemeHandler.swift | 23 +- .../Common/IntegrationTestsBridging.h | 21 + IntegrationTests/Tab/ErrorPageTests.swift | 930 ++++++++++++++++++ .../SearchNonexistentDomainTests.swift | 84 +- .../Extensions/StringExtensionTests.swift | 44 + UnitTests/Common/NSErrorAdditionalInfo.swift | 13 +- UnitTests/Common/TestsBridging.h | 1 + UnitTests/Common/WKURLSchemeTask+Private.h | 30 + .../Common/WKWebViewMockingExtension.swift | 8 +- .../DataImport/DataImportViewModelTests.swift | 3 + UnitTests/Tab/Model/TabTests.swift | 8 +- .../Tab/ViewModel/TabViewModelTests.swift | 17 +- ...bViewPrivateMethodsAvailabilityTests.swift | 4 + .../TabBar/ViewModel/TabLazyLoaderTests.swift | 4 +- 50 files changed, 1846 insertions(+), 505 deletions(-) create mode 100644 DuckDuckGo/Assets.xcassets/Images/Alert-Circle-Color-16.imageset/Alert-Medium-Multicolor-16.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Images/Alert-Circle-Color-16.imageset/Contents.json delete mode 100644 DuckDuckGo/Assets.xcassets/Images/DefaultFavicon.imageset/Contents.json delete mode 100644 DuckDuckGo/Assets.xcassets/Images/DefaultFavicon.imageset/default-fav.png create mode 100644 DuckDuckGo/Assets.xcassets/Images/Globe-Multicolor-16.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/Globe-Multicolor-16.imageset/Globe-Multicolor-16.pdf create mode 100644 DuckDuckGo/Common/Extensions/NSObject+performSelector.h create mode 100644 DuckDuckGo/Common/Extensions/NSObject+performSelector.m create mode 100644 DuckDuckGo/Common/Extensions/WKBackForwardListItemExtension.swift create mode 100644 DuckDuckGo/ErrorPage/ErrorPageHTMLTemplate.swift rename DuckDuckGo/NavigationBar/ViewModel/{WKBackForwardListItemViewModel.swift => BackForwardListItemViewModel.swift} (65%) delete mode 100644 DuckDuckGo/Tab/View/Base.lproj/BrowserTab.storyboard create mode 100644 IntegrationTests/Common/IntegrationTestsBridging.h create mode 100644 IntegrationTests/Tab/ErrorPageTests.swift rename IntegrationTests/{TabExtensions => Tab}/SearchNonexistentDomainTests.swift (77%) create mode 100644 UnitTests/Common/Extensions/StringExtensionTests.swift create mode 100644 UnitTests/Common/WKURLSchemeTask+Private.h diff --git a/Configuration/Tests/IntegrationTests.xcconfig b/Configuration/Tests/IntegrationTests.xcconfig index 6100fbe474..cee1523e37 100644 --- a/Configuration/Tests/IntegrationTests.xcconfig +++ b/Configuration/Tests/IntegrationTests.xcconfig @@ -22,4 +22,6 @@ FEATURE_FLAGS = FEEDBACK NETWORK_PROTECTION DBP INFOPLIST_FILE = IntegrationTests/Info.plist PRODUCT_BUNDLE_IDENTIFIER = com.duckduckgo.Integration-Tests +SWIFT_OBJC_BRIDGING_HEADER = $(SRCROOT)/IntegrationTests/Common/IntegrationTestsBridging.h + TEST_HOST=$(BUILT_PRODUCTS_DIR)/DuckDuckGo.app/Contents/MacOS/DuckDuckGo diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 3c1c8e3cfe..bbbc6133bd 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -265,7 +265,7 @@ 3706FAE1293F65D500E42796 /* HomePageFavoritesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85589E8627BBB8F20038AD11 /* HomePageFavoritesModel.swift */; }; 3706FAE2293F65D500E42796 /* SequenceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4925B7B690006F6B06 /* SequenceExtensions.swift */; }; 3706FAE3293F65D500E42796 /* ChromiumDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023B26B35F3600489384 /* ChromiumDataImporter.swift */; }; - 3706FAE5293F65D500E42796 /* WKBackForwardListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC3B25337FAB0079BC96 /* WKBackForwardListItemViewModel.swift */; }; + 3706FAE5293F65D500E42796 /* BackForwardListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC3B25337FAB0079BC96 /* BackForwardListItemViewModel.swift */; }; 3706FAE6293F65D500E42796 /* BWNotRespondingAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D43EB3329297D760065E5D6 /* BWNotRespondingAlert.swift */; }; 3706FAE7293F65D500E42796 /* DebugUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4425B7B55C006F6B06 /* DebugUserScript.swift */; }; 3706FAE8293F65D500E42796 /* RecentlyClosedTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC6881828626BF800D54247 /* RecentlyClosedTab.swift */; }; @@ -699,7 +699,6 @@ 3706FCEF293F65D500E42796 /* macos-config.json in Resources */ = {isa = PBXBuildFile; fileRef = 026ADE1326C3010C002518EE /* macos-config.json */; }; 3706FCF0293F65D500E42796 /* httpsMobileV2BloomSpec.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B677427255DBEB800025BD8 /* httpsMobileV2BloomSpec.json */; }; 3706FCF1293F65D500E42796 /* TabBarFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = AA2CB12C2587BB5600AA6FBE /* TabBarFooter.xib */; }; - 3706FCF2293F65D500E42796 /* BrowserTab.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC69256C4691007083E7 /* BrowserTab.storyboard */; }; 3706FCF3293F65D500E42796 /* FirePopoverCollectionViewItem.xib in Resources */ = {isa = PBXBuildFile; fileRef = AAE246F22709EF3B00BEEAEE /* FirePopoverCollectionViewItem.xib */; }; 3706FCF4293F65D500E42796 /* ProximaNova-Bold-webfont.woff2 in Resources */ = {isa = PBXBuildFile; fileRef = EAA29AE7278D2E43007070CF /* ProximaNova-Bold-webfont.woff2 */; }; 3706FCF5293F65D500E42796 /* dark-shield-dot.json in Resources */ = {isa = PBXBuildFile; fileRef = AA34396E2754D4E900B241FA /* dark-shield-dot.json */; }; @@ -1402,7 +1401,7 @@ 4B9579BB2AC7AE700062CA31 /* SequenceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4925B7B690006F6B06 /* SequenceExtensions.swift */; }; 4B9579BC2AC7AE700062CA31 /* WKBackForwardListExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B602E7CE2A93A5FF00F12201 /* WKBackForwardListExtension.swift */; }; 4B9579BD2AC7AE700062CA31 /* ChromiumDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023B26B35F3600489384 /* ChromiumDataImporter.swift */; }; - 4B9579BE2AC7AE700062CA31 /* WKBackForwardListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC3B25337FAB0079BC96 /* WKBackForwardListItemViewModel.swift */; }; + 4B9579BE2AC7AE700062CA31 /* BackForwardListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC3B25337FAB0079BC96 /* BackForwardListItemViewModel.swift */; }; 4B9579BF2AC7AE700062CA31 /* BWNotRespondingAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D43EB3329297D760065E5D6 /* BWNotRespondingAlert.swift */; }; 4B9579C02AC7AE700062CA31 /* DebugUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4425B7B55C006F6B06 /* DebugUserScript.swift */; }; 4B9579C12AC7AE700062CA31 /* RecentlyClosedTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC6881828626BF800D54247 /* RecentlyClosedTab.swift */; }; @@ -1984,7 +1983,6 @@ 4B957C282AC7AE700062CA31 /* macos-config.json in Resources */ = {isa = PBXBuildFile; fileRef = 026ADE1326C3010C002518EE /* macos-config.json */; }; 4B957C292AC7AE700062CA31 /* httpsMobileV2BloomSpec.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B677427255DBEB800025BD8 /* httpsMobileV2BloomSpec.json */; }; 4B957C2A2AC7AE700062CA31 /* TabBarFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = AA2CB12C2587BB5600AA6FBE /* TabBarFooter.xib */; }; - 4B957C2B2AC7AE700062CA31 /* BrowserTab.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC69256C4691007083E7 /* BrowserTab.storyboard */; }; 4B957C2C2AC7AE700062CA31 /* FirePopoverCollectionViewItem.xib in Resources */ = {isa = PBXBuildFile; fileRef = AAE246F22709EF3B00BEEAEE /* FirePopoverCollectionViewItem.xib */; }; 4B957C2D2AC7AE700062CA31 /* ProximaNova-Bold-webfont.woff2 in Resources */ = {isa = PBXBuildFile; fileRef = EAA29AE7278D2E43007070CF /* ProximaNova-Bold-webfont.woff2 */; }; 4B957C2E2AC7AE700062CA31 /* dark-shield-dot.json in Resources */ = {isa = PBXBuildFile; fileRef = AA34396E2754D4E900B241FA /* dark-shield-dot.json */; }; @@ -2486,7 +2484,6 @@ AA7EB6EB27E880AE00036718 /* dark-shield-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6EA27E880AE00036718 /* dark-shield-mouse-over.json */; }; AA7EB6ED27E880B600036718 /* dark-shield-dot-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6EC27E880B600036718 /* dark-shield-dot-mouse-over.json */; }; AA80EC54256BE3BC007083E7 /* UserText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA80EC53256BE3BC007083E7 /* UserText.swift */; }; - AA80EC67256C4691007083E7 /* BrowserTab.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC69256C4691007083E7 /* BrowserTab.storyboard */; }; AA80EC73256C46A2007083E7 /* Suggestion.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC75256C46A2007083E7 /* Suggestion.storyboard */; }; AA80EC79256C46AA007083E7 /* TabBar.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC7B256C46AA007083E7 /* TabBar.storyboard */; }; AA840A9827319D1600E63CDD /* FirePopoverWrapperViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA840A9727319D1600E63CDD /* FirePopoverWrapperViewController.swift */; }; @@ -2505,7 +2502,7 @@ AA9FF95D24A1FA1C0039E328 /* TabCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9FF95C24A1FA1C0039E328 /* TabCollection.swift */; }; AA9FF95F24A1FB690039E328 /* TabCollectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9FF95E24A1FB680039E328 /* TabCollectionViewModel.swift */; }; AAA0CC33252F181A0079BC96 /* NavigationButtonMenuDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC32252F181A0079BC96 /* NavigationButtonMenuDelegate.swift */; }; - AAA0CC3C25337FAB0079BC96 /* WKBackForwardListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC3B25337FAB0079BC96 /* WKBackForwardListItemViewModel.swift */; }; + AAA0CC3C25337FAB0079BC96 /* BackForwardListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC3B25337FAB0079BC96 /* BackForwardListItemViewModel.swift */; }; AAA0CC472533833C0079BC96 /* MoreOptionsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC462533833C0079BC96 /* MoreOptionsMenu.swift */; }; AAA0CC572539EBC90079BC96 /* FaviconUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC562539EBC90079BC96 /* FaviconUserScript.swift */; }; AAA0CC6A253CC43C0079BC96 /* WKUserContentControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA0CC69253CC43C0079BC96 /* WKUserContentControllerExtension.swift */; }; @@ -2788,6 +2785,17 @@ B68172AE269EB43F006D1092 /* GeolocationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68172AD269EB43F006D1092 /* GeolocationServiceTests.swift */; }; B6830961274CDE99004B46BB /* FireproofDomainsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6830960274CDE99004B46BB /* FireproofDomainsContainer.swift */; }; B6830963274CDEC7004B46BB /* FireproofDomainsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6830962274CDEC7004B46BB /* FireproofDomainsStore.swift */; }; + B68412142B694BA10092F66A /* NSObject+performSelector.m in Sources */ = {isa = PBXBuildFile; fileRef = B68412132B694BA10092F66A /* NSObject+performSelector.m */; }; + B68412152B694BA10092F66A /* NSObject+performSelector.m in Sources */ = {isa = PBXBuildFile; fileRef = B68412132B694BA10092F66A /* NSObject+performSelector.m */; }; + B68412162B694BA10092F66A /* NSObject+performSelector.m in Sources */ = {isa = PBXBuildFile; fileRef = B68412132B694BA10092F66A /* NSObject+performSelector.m */; }; + B684121C2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B684121B2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift */; }; + B684121D2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B684121B2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift */; }; + B684121E2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B684121B2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift */; }; + B68412202B6A30680092F66A /* StringExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B684121F2B6A30680092F66A /* StringExtensionTests.swift */; }; + B68412212B6A30680092F66A /* StringExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B684121F2B6A30680092F66A /* StringExtensionTests.swift */; }; + B68412272B6A68C10092F66A /* WKBackForwardListItemExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68412242B6A67920092F66A /* WKBackForwardListItemExtension.swift */; }; + B68412282B6A68C20092F66A /* WKBackForwardListItemExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68412242B6A67920092F66A /* WKBackForwardListItemExtension.swift */; }; + B68412292B6A68C90092F66A /* WKBackForwardListItemExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68412242B6A67920092F66A /* WKBackForwardListItemExtension.swift */; }; B68458B025C7E76A00DC17B6 /* WindowManager+StateRestoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68458AF25C7E76A00DC17B6 /* WindowManager+StateRestoration.swift */; }; B68458B825C7E8B200DC17B6 /* Tab+NSSecureCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68458B725C7E8B200DC17B6 /* Tab+NSSecureCoding.swift */; }; B68458C025C7E9E000DC17B6 /* TabCollectionViewModel+NSSecureCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68458BF25C7E9E000DC17B6 /* TabCollectionViewModel+NSSecureCoding.swift */; }; @@ -2818,6 +2826,8 @@ B690152C2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B690152B2ACBF4DA00AD0BAB /* MenuPreview.swift */; }; B690152D2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B690152B2ACBF4DA00AD0BAB /* MenuPreview.swift */; }; B690152F2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B690152B2ACBF4DA00AD0BAB /* MenuPreview.swift */; }; + B693766E2B6B5F27005BD9D4 /* ErrorPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693766D2B6B5F26005BD9D4 /* ErrorPageTests.swift */; }; + B693766F2B6B5F27005BD9D4 /* ErrorPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693766D2B6B5F26005BD9D4 /* ErrorPageTests.swift */; }; B693954B26F04BEB0015B914 /* MouseOverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953D26F04BE70015B914 /* MouseOverView.swift */; }; B693954C26F04BEB0015B914 /* FocusRingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953E26F04BE70015B914 /* FocusRingView.swift */; }; B693954E26F04BEB0015B914 /* LoadingProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954026F04BE80015B914 /* LoadingProgressView.swift */; }; @@ -4016,7 +4026,6 @@ AA7EB6EA27E880AE00036718 /* dark-shield-mouse-over.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "dark-shield-mouse-over.json"; sourceTree = ""; }; AA7EB6EC27E880B600036718 /* dark-shield-dot-mouse-over.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "dark-shield-dot-mouse-over.json"; sourceTree = ""; }; AA80EC53256BE3BC007083E7 /* UserText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserText.swift; sourceTree = ""; }; - AA80EC68256C4691007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/BrowserTab.storyboard; sourceTree = ""; }; AA80EC74256C46A2007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Suggestion.storyboard; sourceTree = ""; }; AA80EC7A256C46AA007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/TabBar.storyboard; sourceTree = ""; }; AA840A9727319D1600E63CDD /* FirePopoverWrapperViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirePopoverWrapperViewController.swift; sourceTree = ""; }; @@ -4035,7 +4044,7 @@ AA9FF95C24A1FA1C0039E328 /* TabCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCollection.swift; sourceTree = ""; }; AA9FF95E24A1FB680039E328 /* TabCollectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCollectionViewModel.swift; sourceTree = ""; }; AAA0CC32252F181A0079BC96 /* NavigationButtonMenuDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationButtonMenuDelegate.swift; sourceTree = ""; }; - AAA0CC3B25337FAB0079BC96 /* WKBackForwardListItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKBackForwardListItemViewModel.swift; sourceTree = ""; }; + AAA0CC3B25337FAB0079BC96 /* BackForwardListItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackForwardListItemViewModel.swift; sourceTree = ""; }; AAA0CC462533833C0079BC96 /* MoreOptionsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreOptionsMenu.swift; sourceTree = ""; }; AAA0CC562539EBC90079BC96 /* FaviconUserScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconUserScript.swift; sourceTree = ""; }; AAA0CC69253CC43C0079BC96 /* WKUserContentControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKUserContentControllerExtension.swift; sourceTree = ""; }; @@ -4225,6 +4234,11 @@ B68172AD269EB43F006D1092 /* GeolocationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeolocationServiceTests.swift; sourceTree = ""; }; B6830960274CDE99004B46BB /* FireproofDomainsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireproofDomainsContainer.swift; sourceTree = ""; }; B6830962274CDEC7004B46BB /* FireproofDomainsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireproofDomainsStore.swift; sourceTree = ""; }; + B68412122B694BA10092F66A /* NSObject+performSelector.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSObject+performSelector.h"; sourceTree = ""; }; + B68412132B694BA10092F66A /* NSObject+performSelector.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSObject+performSelector.m"; sourceTree = ""; }; + B684121B2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPageHTMLTemplate.swift; sourceTree = ""; }; + B684121F2B6A30680092F66A /* StringExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensionTests.swift; sourceTree = ""; }; + B68412242B6A67920092F66A /* WKBackForwardListItemExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKBackForwardListItemExtension.swift; sourceTree = ""; }; B68458AF25C7E76A00DC17B6 /* WindowManager+StateRestoration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowManager+StateRestoration.swift"; sourceTree = ""; }; B68458B725C7E8B200DC17B6 /* Tab+NSSecureCoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tab+NSSecureCoding.swift"; sourceTree = ""; }; B68458BF25C7E9E000DC17B6 /* TabCollectionViewModel+NSSecureCoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabCollectionViewModel+NSSecureCoding.swift"; sourceTree = ""; }; @@ -4243,6 +4257,7 @@ B68C92C0274E3EF4002AC6B0 /* PopUpWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpWindow.swift; sourceTree = ""; }; B68C92C32750EF76002AC6B0 /* PixelDataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelDataRecord.swift; sourceTree = ""; }; B690152B2ACBF4DA00AD0BAB /* MenuPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPreview.swift; sourceTree = ""; }; + B693766D2B6B5F26005BD9D4 /* ErrorPageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorPageTests.swift; sourceTree = ""; }; B693953D26F04BE70015B914 /* MouseOverView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MouseOverView.swift; sourceTree = ""; }; B693953E26F04BE70015B914 /* FocusRingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusRingView.swift; sourceTree = ""; }; B693954026F04BE80015B914 /* LoadingProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingProgressView.swift; sourceTree = ""; }; @@ -4289,6 +4304,8 @@ B6A5A27D25B9403E00AA7ADA /* FileStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStoreMock.swift; sourceTree = ""; }; B6A5A29F25B96E8300AA7ADA /* AppStateChangePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateChangePublisherTests.swift; sourceTree = ""; }; B6A5A2A725BAA35500AA7ADA /* WindowManagerStateRestorationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManagerStateRestorationTests.swift; sourceTree = ""; }; + B6A60E4F2B73C3B800FD4968 /* WKURLSchemeTask+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WKURLSchemeTask+Private.h"; sourceTree = ""; }; + B6A60E502B73C46B00FD4968 /* IntegrationTestsBridging.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = IntegrationTestsBridging.h; sourceTree = ""; }; B6A924D82664C72D001A28CA /* WebKitDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitDownloadTask.swift; sourceTree = ""; }; B6A9E45226142B070067D1B9 /* Pixel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Pixel.swift; sourceTree = ""; }; B6A9E46A2614618A0067D1B9 /* OperatingSystemVersionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersionExtension.swift; sourceTree = ""; }; @@ -5243,7 +5260,7 @@ B603973229BEF84900902A34 /* HTTPSUpgrade */, B62A233A29C322A000D22475 /* NavigationProtection */, B603973629BF0E9400902A34 /* PrivacyDashboard */, - B644B43C29D56811003FA9AB /* TabExtensions */, + B644B43C29D56811003FA9AB /* Tab */, 4B1AD91625FC46FB00261379 /* CoreDataEncryptionTests.swift */, 4BA1A6EA258C288C00F6F690 /* EncryptionKeyStoreTests.swift */, 4B1AD8A125FC27E200261379 /* Info.plist */, @@ -6481,6 +6498,7 @@ B6DA06E02913AEDB00225DE2 /* TestNavigationDelegate.swift */, B60C6F8029B1B4AD007BFAA8 /* TestRunHelper.swift */, B60C6F7D29B1B41D007BFAA8 /* TestRunHelperInitializer.m */, + B6A60E4F2B73C3B800FD4968 /* WKURLSchemeTask+Private.h */, ); path = Common; sourceTree = ""; @@ -6670,21 +6688,21 @@ isa = PBXGroup; children = ( B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */, - 3192EC862A4DCF0E001E97A5 /* DBP */, - EEAEA3F4294D05CF00D04DF3 /* JSAlert */, + AA4D700525545EDE00C3411E /* Application */, B31055BB27A1BA0E001AC618 /* Autoconsent */, 7B1E819A27C8874900FF0E60 /* Autofill */, - AA4D700525545EDE00C3411E /* Application */, AAC5E4C025D6A6A9007F5990 /* Bookmarks */, 4BFD356E283ADE8B00CE9234 /* BookmarksBar */, AA86491324D831B9001BABEE /* Common */, 85D33F1025C82E93002B91A6 /* Configuration */, 4B6160D125B14E5E007DE5B2 /* ContentBlocker */, AAC30A24268DF93500D2D9CD /* CrashReports */, - 4B723DEA26B0002B00E14D75 /* DataImport */, 4B723DF826B0002B00E14D75 /* DataExport */, + 4B723DEA26B0002B00E14D75 /* DataImport */, + 3192EC862A4DCF0E001E97A5 /* DBP */, 4B379C1C27BDB7EA008A968E /* DeviceAuthentication */, 4B65143C26392483005B46EB /* Email */, + B68412192B6A16030092F66A /* ErrorPage */, AA5FA695275F823900DCE9C9 /* Favicons */, 1D36E651298A84F600AA485D /* FeatureFlagging */, AA3863C227A1E1C000749AB5 /* Feedback */, @@ -6694,13 +6712,13 @@ 4B02197B25E05FAC00ED7DEA /* Fireproofing */, B65536902684409300085A79 /* Geolocation */, AAE75275263B036300B973F8 /* History */, + AAE71DB225F66A0900D74437 /* HomePage */, + EEAEA3F4294D05CF00D04DF3 /* JSAlert */, 9D03F5A22AA74829001A50E8 /* LoginItems */, AA585DB02490E6FA00E9A3E2 /* MainWindow */, - AAE71DB225F66A0900D74437 /* HomePage */, AA97BF4425135CB60014931A /* Menus */, 85378D9A274E618C007C5CBF /* MessageViews */, AA86491524D83384001BABEE /* NavigationBar */, - 4B41EDAC2B168A66001EEDF4 /* VPNFeedbackForm */, 4B4D60542A0B29FA00BCD287 /* NetworkProtection */, 85B7184727677A7D00B4277F /* Onboarding */, 1D074B252909A371006E4AC3 /* PasswordManager */, @@ -6721,6 +6739,7 @@ AAE8B0FD258A416F00E81239 /* TabPreview */, B6040859274B8C5200680351 /* UnprotectedDomains */, AACF6FD426BC35C200CF09F9 /* UserAgent */, + 4B41EDAC2B168A66001EEDF4 /* VPNFeedbackForm */, 4B9DB0062A983B23000927DB /* Waitlist */, AA6EF9AE25066F99004754E6 /* Windows */, 31F28C4B28C8EE9000119F70 /* YoutubePlayer */, @@ -7109,7 +7128,6 @@ AA86491C24D83868001BABEE /* View */ = { isa = PBXGroup; children = ( - AA80EC69256C4691007083E7 /* BrowserTab.storyboard */, B6C0BB6929AF1C7000AE8E3C /* BrowserTabView.swift */, AA585D83248FD31100E9A3E2 /* BrowserTabViewController.swift */, AA6FFB4524DC3B5A0028F4D0 /* WebView.swift */, @@ -7255,7 +7273,7 @@ AAA0CC3A25337F990079BC96 /* ViewModel */ = { isa = PBXGroup; children = ( - AAA0CC3B25337FAB0079BC96 /* WKBackForwardListItemViewModel.swift */, + AAA0CC3B25337FAB0079BC96 /* BackForwardListItemViewModel.swift */, B689ECD426C247DB006FB0C5 /* BackForwardListItem.swift */, AA75A0AD26F3500C0086B667 /* PrivacyIconViewModel.swift */, ); @@ -7524,6 +7542,8 @@ AA6EF9B2250785D5004754E6 /* NSMenuExtension.swift */, AA72D5FD25FFF94E00C77619 /* NSMenuItemExtension.swift */, 4B980E202817604000282EE1 /* NSNotificationName+Debug.swift */, + B68412122B694BA10092F66A /* NSObject+performSelector.h */, + B68412132B694BA10092F66A /* NSObject+performSelector.m */, 4B0511DF262CAA8600F6079C /* NSOpenPanelExtensions.swift */, 4B0135CD2729F1AA00D54834 /* NSPasteboardExtension.swift */, 4B85A47F28821CC500FC4C39 /* NSPasteboardItemExtension.swift */, @@ -7556,6 +7576,7 @@ AA88D14A252A557100980B4E /* URLRequestExtension.swift */, B6DB3AEE278D5C370024C5C4 /* URLSessionExtension.swift */, B602E7CE2A93A5FF00F12201 /* WKBackForwardListExtension.swift */, + B68412242B6A67920092F66A /* WKBackForwardListItemExtension.swift */, B6DA06E7291401D700225DE2 /* WKMenuItemIdentifier.swift */, B645D8F529FA95440024461F /* WKProcessPoolExtension.swift */, AAA0CC69253CC43C0079BC96 /* WKUserContentControllerExtension.swift */, @@ -7683,6 +7704,7 @@ 85F69B3B25EDE81F00978E59 /* URLExtensionTests.swift */, 4B8AD0B027A86D9200AE44D6 /* WKWebsiteDataStoreExtensionTests.swift */, B6AA64722994B43300D99CD6 /* FutureExtensionTests.swift */, + B684121F2B6A30680092F66A /* StringExtensionTests.swift */, ); path = Extensions; sourceTree = ""; @@ -7727,6 +7749,7 @@ B603972A29BEDF0F00902A34 /* Common */ = { isa = PBXGroup; children = ( + B6A60E502B73C46B00FD4968 /* IntegrationTestsBridging.h */, B6EC37FB29B83E99001ACE79 /* TestsURLExtension.swift */, ); path = Common; @@ -7799,12 +7822,13 @@ path = History; sourceTree = ""; }; - B644B43C29D56811003FA9AB /* TabExtensions */ = { + B644B43C29D56811003FA9AB /* Tab */ = { isa = PBXGroup; children = ( B644B43929D565DB003FA9AB /* SearchNonexistentDomainTests.swift */, + B693766D2B6B5F26005BD9D4 /* ErrorPageTests.swift */, ); - path = TabExtensions; + path = Tab; sourceTree = ""; }; B647EFB32922539400BA628D /* TabExtensions */ = { @@ -7900,6 +7924,14 @@ path = Database; sourceTree = ""; }; + B68412192B6A16030092F66A /* ErrorPage */ = { + isa = PBXGroup; + children = ( + B684121B2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift */, + ); + path = ErrorPage; + sourceTree = ""; + }; B68458AE25C7E75100DC17B6 /* StateRestoration */ = { isa = PBXGroup; children = ( @@ -8875,7 +8907,6 @@ 3706FCEF293F65D500E42796 /* macos-config.json in Resources */, 3706FCF0293F65D500E42796 /* httpsMobileV2BloomSpec.json in Resources */, 3706FCF1293F65D500E42796 /* TabBarFooter.xib in Resources */, - 3706FCF2293F65D500E42796 /* BrowserTab.storyboard in Resources */, 3706FCF3293F65D500E42796 /* FirePopoverCollectionViewItem.xib in Resources */, 3706FCF4293F65D500E42796 /* ProximaNova-Bold-webfont.woff2 in Resources */, 3706FCF5293F65D500E42796 /* dark-shield-dot.json in Resources */, @@ -9003,7 +9034,6 @@ 4B957C282AC7AE700062CA31 /* macos-config.json in Resources */, 4B957C292AC7AE700062CA31 /* httpsMobileV2BloomSpec.json in Resources */, 4B957C2A2AC7AE700062CA31 /* TabBarFooter.xib in Resources */, - 4B957C2B2AC7AE700062CA31 /* BrowserTab.storyboard in Resources */, 4B957C2C2AC7AE700062CA31 /* FirePopoverCollectionViewItem.xib in Resources */, 4B957C2D2AC7AE700062CA31 /* ProximaNova-Bold-webfont.woff2 in Resources */, 4B957C2E2AC7AE700062CA31 /* dark-shield-dot.json in Resources */, @@ -9100,7 +9130,6 @@ 026ADE1426C3010C002518EE /* macos-config.json in Resources */, 4B677432255DBEB800025BD8 /* httpsMobileV2BloomSpec.json in Resources */, AA2CB12D2587BB5600AA6FBE /* TabBarFooter.xib in Resources */, - AA80EC67256C4691007083E7 /* BrowserTab.storyboard in Resources */, AAE246F42709EF3B00BEEAEE /* FirePopoverCollectionViewItem.xib in Resources */, EAA29AE9278D2E43007070CF /* ProximaNova-Bold-webfont.woff2 in Resources */, AA3439702754D4E900B241FA /* dark-shield-dot.json in Resources */, @@ -9568,7 +9597,7 @@ 3706FAE1293F65D500E42796 /* HomePageFavoritesModel.swift in Sources */, 3706FAE2293F65D500E42796 /* SequenceExtensions.swift in Sources */, 3706FAE3293F65D500E42796 /* ChromiumDataImporter.swift in Sources */, - 3706FAE5293F65D500E42796 /* WKBackForwardListItemViewModel.swift in Sources */, + 3706FAE5293F65D500E42796 /* BackForwardListItemViewModel.swift in Sources */, 3706FAE6293F65D500E42796 /* BWNotRespondingAlert.swift in Sources */, 3706FAE7293F65D500E42796 /* DebugUserScript.swift in Sources */, 3706FAE8293F65D500E42796 /* RecentlyClosedTab.swift in Sources */, @@ -9884,6 +9913,8 @@ 3706FBD0293F65D500E42796 /* WebKitVersionProvider.swift in Sources */, 3706FBD1293F65D500E42796 /* NSCoderExtensions.swift in Sources */, B6D6A5DD2982A4CE001F5F11 /* Tab+Navigation.swift in Sources */, + B68412282B6A68C20092F66A /* WKBackForwardListItemExtension.swift in Sources */, + B684121D2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift in Sources */, 3706FBD2293F65D500E42796 /* RunningApplicationCheck.swift in Sources */, 3706FBD3293F65D500E42796 /* StatePersistenceService.swift in Sources */, 3706FBD4293F65D500E42796 /* WindowManager+StateRestoration.swift in Sources */, @@ -10037,6 +10068,7 @@ 3706FC3C293F65D500E42796 /* HistoryCoordinator.swift in Sources */, 3706FC3E293F65D500E42796 /* VariantManager.swift in Sources */, 3706FC3F293F65D500E42796 /* ApplicationDockMenu.swift in Sources */, + B68412152B694BA10092F66A /* NSObject+performSelector.m in Sources */, 4B9DB03C2A983B24000927DB /* InvitedToWaitlistView.swift in Sources */, 3706FC40293F65D500E42796 /* SaveIdentityViewController.swift in Sources */, 3706FC41293F65D500E42796 /* FileStore.swift in Sources */, @@ -10242,6 +10274,7 @@ 3706FE13293F661700E42796 /* ConfigurationStorageTests.swift in Sources */, 3706FE14293F661700E42796 /* DownloadListStoreMock.swift in Sources */, 3706FE15293F661700E42796 /* PrivacyIconViewModelTests.swift in Sources */, + B68412212B6A30680092F66A /* StringExtensionTests.swift in Sources */, 1D8C2FEE2B70F5D0005E4BBD /* MockViewSnapshotRenderer.swift in Sources */, 56D145F229E6F06D00E3488A /* MockBookmarkManager.swift in Sources */, 3706FE16293F661700E42796 /* CSVImporterTests.swift in Sources */, @@ -10434,6 +10467,7 @@ B603972D29BEDF2100902A34 /* ExpectedNavigationExtension.swift in Sources */, 3706FEA6293F662100E42796 /* EncryptionKeyStoreTests.swift in Sources */, B6F5656A299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, + B693766F2B6B5F27005BD9D4 /* ErrorPageTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -10474,6 +10508,7 @@ B603972C29BEDF2100902A34 /* ExpectedNavigationExtension.swift in Sources */, 4B1AD8D525FC38DD00261379 /* EncryptionKeyStoreTests.swift in Sources */, B6F56568299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, + B693766E2B6B5F27005BD9D4 /* ErrorPageTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -10735,7 +10770,7 @@ 4B9579BB2AC7AE700062CA31 /* SequenceExtensions.swift in Sources */, 4B9579BC2AC7AE700062CA31 /* WKBackForwardListExtension.swift in Sources */, 4B9579BD2AC7AE700062CA31 /* ChromiumDataImporter.swift in Sources */, - 4B9579BE2AC7AE700062CA31 /* WKBackForwardListItemViewModel.swift in Sources */, + 4B9579BE2AC7AE700062CA31 /* BackForwardListItemViewModel.swift in Sources */, 4B9579BF2AC7AE700062CA31 /* BWNotRespondingAlert.swift in Sources */, 4B9579C02AC7AE700062CA31 /* DebugUserScript.swift in Sources */, 1DC669722B6CF0D700AA0645 /* TabSnapshotStore.swift in Sources */, @@ -10959,6 +10994,7 @@ 4B957A842AC7AE700062CA31 /* PreferencesDownloadsView.swift in Sources */, 4B957A852AC7AE700062CA31 /* QRSharingService.swift in Sources */, 4B957A862AC7AE700062CA31 /* ProcessExtension.swift in Sources */, + B68412162B694BA10092F66A /* NSObject+performSelector.m in Sources */, 4B957A872AC7AE700062CA31 /* PermissionAuthorizationQuery.swift in Sources */, 4B957A882AC7AE700062CA31 /* BadgeAnimationView.swift in Sources */, 4B957A892AC7AE700062CA31 /* BrowserTabSelectionDelegate.swift in Sources */, @@ -11212,6 +11248,7 @@ 4B957B6C2AC7AE700062CA31 /* MouseOverAnimationButton.swift in Sources */, 4B957B6D2AC7AE700062CA31 /* TabBarScrollView.swift in Sources */, B6B5F5822B024105008DB58A /* DataImportSummaryView.swift in Sources */, + B684121E2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift in Sources */, 4B957B6E2AC7AE700062CA31 /* BookmarkListTreeControllerDataSource.swift in Sources */, 4B957B6F2AC7AE700062CA31 /* AddressBarViewController.swift in Sources */, 4B957B702AC7AE700062CA31 /* Permissions.swift in Sources */, @@ -11226,6 +11263,7 @@ 4B957B782AC7AE700062CA31 /* ContentOverlayViewController.swift in Sources */, 4B957B792AC7AE700062CA31 /* ContentBlockingTabExtension.swift in Sources */, 4B957B7A2AC7AE700062CA31 /* OnboardingViewController.swift in Sources */, + B68412292B6A68C90092F66A /* WKBackForwardListItemExtension.swift in Sources */, 4B957B7B2AC7AE700062CA31 /* DeviceAuthenticator.swift in Sources */, 4B957B7C2AC7AE700062CA31 /* NetworkProtectionWaitlistFeatureFlagOverridesMenu.swift in Sources */, 4B957B7D2AC7AE700062CA31 /* TabBarCollectionView.swift in Sources */, @@ -11436,6 +11474,7 @@ 4BF0E5122AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, AA6EF9AD25066F42004754E6 /* WindowsManager.swift in Sources */, 1D43EB3A292B63B00065E5D6 /* BWRequest.swift in Sources */, + B684121C2B6A1D880092F66A /* ErrorPageHTMLTemplate.swift in Sources */, B68458CD25C7EB9000DC17B6 /* WKWebViewConfigurationExtensions.swift in Sources */, 85AC7ADD27BEB6EE00FFB69B /* HomePageDefaultBrowserModel.swift in Sources */, B6619EFB2B111CC500CD9186 /* InstructionsFormatParser.swift in Sources */, @@ -11506,7 +11545,7 @@ B602E7CF2A93A5FF00F12201 /* WKBackForwardListExtension.swift in Sources */, 4B59024026B35F3600489384 /* ChromiumDataImporter.swift in Sources */, B62B48562ADE730D000DECE5 /* FileImportView.swift in Sources */, - AAA0CC3C25337FAB0079BC96 /* WKBackForwardListItemViewModel.swift in Sources */, + AAA0CC3C25337FAB0079BC96 /* BackForwardListItemViewModel.swift in Sources */, 1D43EB3429297D760065E5D6 /* BWNotRespondingAlert.swift in Sources */, 4BB88B4525B7B55C006F6B06 /* DebugUserScript.swift in Sources */, AAC6881928626BF800D54247 /* RecentlyClosedTab.swift in Sources */, @@ -11585,6 +11624,7 @@ 1D36E658298AA3BA00AA485D /* InternalUserDeciderStore.swift in Sources */, B634DBE5293C944700C3C99E /* NewWindowPolicy.swift in Sources */, 31CF3432288B0B1B0087244B /* NavigationBarBadgeAnimator.swift in Sources */, + B68412272B6A68C10092F66A /* WKBackForwardListItemExtension.swift in Sources */, 858A798526A8BB5D00A75A42 /* NSTextViewExtension.swift in Sources */, B634DBE7293C98C500C3C99E /* FutureExtension.swift in Sources */, B634DBE3293C900000C3C99E /* UserDialogRequest.swift in Sources */, @@ -11665,6 +11705,7 @@ 4BA1A6BD258B082300F6F690 /* EncryptionKeyStore.swift in Sources */, B6C00ED7292FB4B4009C73A6 /* TabExtensionsBuilder.swift in Sources */, 4BE65474271FCD40008D1D63 /* PasswordManagementIdentityItemView.swift in Sources */, + B68412142B694BA10092F66A /* NSObject+performSelector.m in Sources */, B6F41031264D2B23003DA42C /* ProgressExtension.swift in Sources */, 4B723E0F26B0006500E14D75 /* CSVParser.swift in Sources */, B6DA44082616B30600DD1EC2 /* PixelDataModel.xcdatamodeld in Sources */, @@ -12184,6 +12225,7 @@ 4B3F641E27A8D3BD00E0C118 /* BrowserProfileTests.swift in Sources */, B6106BA026A7BE0B0013B453 /* PermissionManagerTests.swift in Sources */, 4B59CC8C290083240058F2F6 /* ConnectBitwardenViewModelTests.swift in Sources */, + B68412202B6A30680092F66A /* StringExtensionTests.swift in Sources */, B662D3D92755D7AD0035D4D6 /* PixelStoreTests.swift in Sources */, B6106BB526A809E60013B453 /* GeolocationProviderTests.swift in Sources */, B6A5A2A025B96E8300AA7ADA /* AppStateChangePublisherTests.swift in Sources */, @@ -12494,14 +12536,6 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ - AA80EC69256C4691007083E7 /* BrowserTab.storyboard */ = { - isa = PBXVariantGroup; - children = ( - AA80EC68256C4691007083E7 /* Base */, - ); - name = BrowserTab.storyboard; - sourceTree = ""; - }; AA80EC75256C46A2007083E7 /* Suggestion.storyboard */ = { isa = PBXVariantGroup; children = ( diff --git a/DuckDuckGo/Assets.xcassets/Images/Alert-Circle-Color-16.imageset/Alert-Medium-Multicolor-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Alert-Circle-Color-16.imageset/Alert-Medium-Multicolor-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9d34524e44837add44d2b47fabc12e13d5ba3454 GIT binary patch literal 2374 zcmbVO+iuh_5PkPo_@z>dw2m)voJdtAx}^vKqHKAqcnI0FU9_9PCPl#4GvnCfw2QmNc=o34?#6p=09@g&VcfU% zj%63^w@uToUY_&i_54>eu&+!B=|3)`u^?-~5uafQ`>rXi>T&w~ffb#9k6R2YUFACvGxG4tG z@TV;~Kp}X{BZ5$JyhslAMKwC(Gr^v^v` zp2+Y$?rb^rxRtsx@OlYZsMzCWd=t_o+p%0IlS>KZq3492D!8;|VXF#j9G(->@GUky zWF$ex;dVlRcc_|tCkYB??vlWnNRp#fq9kFgFx86Iu`ferN{d{T8ZNgHwp0%8uPWjq z5fTK65F?>8>o=<;17y-p?ubD_5rGhVy+e3}k#V?00=~nZ`DYYIUYKWb_~D$JhN5!D z&`it;hFO_C@49{*_{~R{o$zHJetc>CaiqNq&}tUwQ}HrIHSu4{*-OzG(5%?(IA%b;Ur_ufdD4s3)Z6bEAOAX zY`4KW?w`Mw(X{AUO36QE8Ce7z7!U;WlAgccO*T)RrM-IXV?hpw@9V<)Z9dB^<31B1 V?3=N}{|?X@44$rjF6*2UngE(4L1+K~ diff --git a/DuckDuckGo/Assets.xcassets/Images/Globe-Multicolor-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Globe-Multicolor-16.imageset/Contents.json new file mode 100644 index 0000000000..771bdd6276 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Globe-Multicolor-16.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Globe-Multicolor-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Globe-Multicolor-16.imageset/Globe-Multicolor-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Globe-Multicolor-16.imageset/Globe-Multicolor-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..35a5fd2eea1a33da37d59f23c97ac76503ee6df3 GIT binary patch literal 4115 zcmeHKOOG5i5Wf3Y_!22WipTGV6h$Jt1R+2y6K)ZQp|`Ucv@-)U6BP36`O57s_s&L0 zaD_RnykEP@RbRdACpXuxpBt5gFjC9IFTV;YU%ZenUy5;mOaBVH#8=;q`@8dd=>V>2 z*Xev5H#@Pq9{<=5uoGH^s8jZkj7f)_ zu_!6}WaMesi=I%?dXK~{1QN|eWI_JIcS>M(mQViA(aQvKq&`C~f8#|>U1O*h zfKrRrU~88E$1YWFd262INByLQ(pcnCwy7XxLpI48Y2y5m1v-T0&?NF=WB)#XEsL+L z2Ewen9cSe%K`pHr#R7~_TWRJo#tT-(2rHoc<$rpY+EEQUuXS29Kn?&3DaTNXlMJyA zxG-DEDtLjEu0P4!8G#&YWpFmAHjanpn63k&t>Yp#tYop`e$27YpzBP$kyOi1Y41O*MX(`<>xW9(B|Ic)+~9$f(q z9@;S4oW+EosT5pJq1#*Y`of3%$+MacJsrujmLXmh9H9do)5e(?F;gs#(0Sn(ZW`!L zl!@If6^n&Y-75)|LOSXNgg1+QSavfR3h3#aFJOXXg zjApcsnh6UE>T@x$!(*n~K#DU$YcU+bx(8>Lyv9U^Wl=@UZ%j_35DI*iNr6{q5zm<< zK8DMFKue0oMqJ)_S5eNn975Mr6bK2hU~wKI7&LAt3$p+rkO&A&Znk%b&nEIO-LBz9 zKbfR?YrQ$L%0@48*dudms9Ox^6mLD8UxW%^3`kA`utgM*_r_uLqYkF912QEcx_~%4 zkgz4kjQbWR(cH!;hg+s~hz*`v>xCE9IY`-Ja97JyW6rp=QQ!1nKg?i}_XMKI991UQ z2rvo=CgWU6C_eUiS#v;})4e!>k=MxkUc{sTMV7-DU0@gWqn_rt zby{SBTjcrQPoJLYysxJ^YvWvBo=OzzqETp|sG5fr!h?%s?q1ZkcFDkE>e2hHJQk%K2buseRWIdAR{cMnBB9>;e=%Os|GKz}7(5-4H7 z?Pu@~pr70`CT n`)nd*wLToqk{qrCuik7wjj+Bx-#5o|b;sr6#FHmq{qXuP + +NS_ASSUME_NONNULL_BEGIN + +@interface NSObject (performSelector) +- (id)performSelector:(SEL)selector withArguments:(NSArray *)arguments; +@end + +NS_ASSUME_NONNULL_END diff --git a/DuckDuckGo/Common/Extensions/NSObject+performSelector.m b/DuckDuckGo/Common/Extensions/NSObject+performSelector.m new file mode 100644 index 0000000000..d5b7a7bb08 --- /dev/null +++ b/DuckDuckGo/Common/Extensions/NSObject+performSelector.m @@ -0,0 +1,52 @@ +// +// NSObject+performSelector.m +// +// 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 "NSObject+performSelector.h" + +@implementation NSObject (performSelector) + +- (id)performSelector:(SEL)selector withArguments:(NSArray *)arguments { + NSMethodSignature *methodSignature = [self methodSignatureForSelector:selector]; + + if (!methodSignature) { + [[[NSException alloc] initWithName:@"InvalidSelectorOrTarget" reason:[NSString stringWithFormat:@"Could not get method signature for selector %@ on %@", NSStringFromSelector(selector), self] userInfo:nil] raise]; + } + + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature]; + [invocation setSelector:selector]; + [invocation setTarget:self]; + + for (NSInteger i = 0; i < arguments.count; i++) { + id argument = arguments[i]; + [invocation setArgument:&argument atIndex:i + 2]; // Indices 0 and 1 are reserved for target and selector + } + + [invocation invoke]; + + if (methodSignature.methodReturnLength > 0) { + id returnValue; + [invocation getReturnValue:&returnValue]; + + return returnValue; + } + + return nil; +} + + +@end diff --git a/DuckDuckGo/Common/Extensions/StringExtension.swift b/DuckDuckGo/Common/Extensions/StringExtension.swift index 53cd3148a2..b6d6affbb0 100644 --- a/DuckDuckGo/Common/Extensions/StringExtension.swift +++ b/DuckDuckGo/Common/Extensions/StringExtension.swift @@ -16,8 +16,9 @@ // limitations under the License. // -import Foundation import BrowserServicesKit +import Common +import Foundation import UniformTypeIdentifiers extension String { @@ -32,6 +33,42 @@ extension String { self.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + } + + private static let unicodeHtmlCharactersMapping: [Character: String] = [ + "&": "&", + "\"": """, + "'": "'", + "<": "<", + ">": ">", + "/": "/", + "!": "!", + "$": "$", + "%": "%", + "=": "=", + "#": "#", + "@": "@", + "[": "[", + "\\": "\", + "]": "]", + "^": "^", + "`": "a", + "{": "{", + "}": "}", + ] + func escapedUnicodeHtmlString() -> String { + var result = "" + + for character in self { + if let mapped = Self.unicodeHtmlCharactersMapping[character] { + result.append(mapped) + } else { + result.append(character) + } + } + + return result } init(_ staticString: StaticString) { diff --git a/DuckDuckGo/Common/Extensions/URLExtension.swift b/DuckDuckGo/Common/Extensions/URLExtension.swift index 7ab5a0deff..51144f4e3d 100644 --- a/DuckDuckGo/Common/Extensions/URLExtension.swift +++ b/DuckDuckGo/Common/Extensions/URLExtension.swift @@ -130,6 +130,8 @@ extension URL { static let welcome = URL(string: "duck://welcome")! static let settings = URL(string: "duck://settings")! static let bookmarks = URL(string: "duck://bookmarks")! + // base url for Error Page Alternate HTML loaded into Web View + static let error = URL(string: "duck://error")! static let dataBrokerProtection = URL(string: "duck://dbp")! diff --git a/DuckDuckGo/Common/Extensions/WKBackForwardListItemExtension.swift b/DuckDuckGo/Common/Extensions/WKBackForwardListItemExtension.swift new file mode 100644 index 0000000000..909d3bcc9c --- /dev/null +++ b/DuckDuckGo/Common/Extensions/WKBackForwardListItemExtension.swift @@ -0,0 +1,35 @@ +// +// WKBackForwardListItemExtension.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 WebKit + +extension WKBackForwardListItem { + + // sometimes WKBackForwardListItem returns wrong or outdated title + private static let tabTitleKey = UnsafeRawPointer(bitPattern: "tabTitleKey".hashValue)! + var tabTitle: String? { + get { + objc_getAssociatedObject(self, Self.tabTitleKey) as? String + } + set { + objc_setAssociatedObject(self, Self.tabTitleKey, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } + +} diff --git a/DuckDuckGo/Common/Extensions/WKWebViewExtension.swift b/DuckDuckGo/Common/Extensions/WKWebViewExtension.swift index 01df6a149a..a2eb4706e4 100644 --- a/DuckDuckGo/Common/Extensions/WKWebViewExtension.swift +++ b/DuckDuckGo/Common/Extensions/WKWebViewExtension.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Common import Navigation import WebKit @@ -231,6 +232,21 @@ extension WKWebView { self.evaluateJavaScript("window.open(\(urlEnc), '_blank', 'noopener, noreferrer')") } + func loadAlternateHTML(_ html: String, baseURL: URL, forUnreachableURL failingURL: URL) { + guard responds(to: Selector.loadAlternateHTMLString) else { + if #available(macOS 12.0, *) { + os_log(.error, log: .navigation, "WKWebView._loadAlternateHTMLString not available") + loadSimulatedRequest(URLRequest(url: failingURL), responseHTML: html) + } + return + } + self.perform(Selector.loadAlternateHTMLString, withArguments: [html, baseURL, failingURL]) + } + + func setDocumentHtml(_ html: String) { + self.evaluateJavaScript("document.open(); document.write('\(html.escapedJavaScriptString())'); document.close()", in: nil, in: .defaultClient) + } + @MainActor var mimeType: String? { get async { @@ -285,6 +301,7 @@ extension WKWebView { enum Selector { static let fullScreenPlaceholderView = NSSelectorFromString("_fullScreenPlaceholderView") static let printOperationWithPrintInfoForFrame = NSSelectorFromString("_printOperationWithPrintInfo:forFrame:") + static let loadAlternateHTMLString = NSSelectorFromString("_loadAlternateHTMLString:baseURL:forUnreachableURL:") } } diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 985fd7fcaa..e08d3ae946 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -17,6 +17,7 @@ // import Foundation +import Navigation struct UserText { @@ -212,12 +213,15 @@ struct UserText { static let tabPreferencesTitle = NSLocalizedString("tab.preferences.title", value: "Settings", comment: "Tab preferences title") static let tabBookmarksTitle = NSLocalizedString("tab.bookmarks.title", value: "Bookmarks", comment: "Tab bookmarks title") static let tabOnboardingTitle = NSLocalizedString("tab.onboarding.title", value: "Welcome", comment: "Tab onboarding title") - static let tabErrorTitle = NSLocalizedString("tab.error.title", value: "Oops!", comment: "Tab error title") + static let tabErrorTitle = NSLocalizedString("tab.error.title", value: "Failed to open page", comment: "Tab error title") + static let errorPageHeader = NSLocalizedString("page.error.header", value: "DuckDuckGo can’t load this page.", comment: "Error page heading text") + static let webProcessCrashPageHeader = NSLocalizedString("page.crash.header", value: "This webpage has crashed.", comment: "Error page heading text shown when a Web Page process had crashed") + static let webProcessCrashPageMessage = NSLocalizedString("page.crash.message", value: "Try reloading the page or come back later.", comment: "Error page message text shown when a Web Page process had crashed") + static let openSystemPreferences = NSLocalizedString("open.preferences", value: "Open System Preferences", comment: "Open System Preferences (to re-enable permission for the App) (up to and including macOS 12") static let openSystemSettings = NSLocalizedString("open.settings", value: "Open System Settings…", comment: "") static let checkForUpdate = NSLocalizedString("check.for.update", value: "Check for Update", comment: "Button users can use to check for a new update") - static let unknownErrorMessage = NSLocalizedString("error.unknown", value: "An unknown error has occurred", comment: "Error page subtitle") static let unknownErrorTryAgainMessage = NSLocalizedString("error.unknown.try.again", value: "An unknown error has occurred", comment: "Generic error message on a dialog for when the cause is not known.") static let moveTabToNewWindow = NSLocalizedString("options.menu.move.tab.to.new.window", diff --git a/DuckDuckGo/Common/View/AppKit/ColorView.swift b/DuckDuckGo/Common/View/AppKit/ColorView.swift index 07e5a383f5..dd48e068e1 100644 --- a/DuckDuckGo/Common/View/AppKit/ColorView.swift +++ b/DuckDuckGo/Common/View/AppKit/ColorView.swift @@ -29,6 +29,7 @@ internal class ColorView: NSView { init(frame: NSRect, backgroundColor: NSColor? = nil, cornerRadius: CGFloat = 0, borderColor: NSColor? = nil, borderWidth: CGFloat = 0, interceptClickEvents: Bool = false) { super.init(frame: frame) + self.translatesAutoresizingMaskIntoConstraints = false self.backgroundColor = backgroundColor self.cornerRadius = cornerRadius self.borderColor = borderColor diff --git a/DuckDuckGo/DataExport/BookmarksExporter.swift b/DuckDuckGo/DataExport/BookmarksExporter.swift index c089132d9c..eb910ed472 100644 --- a/DuckDuckGo/DataExport/BookmarksExporter.swift +++ b/DuckDuckGo/DataExport/BookmarksExporter.swift @@ -34,7 +34,7 @@ struct BookmarksExporter { for entity in entities { if let bookmark = entity as? Bookmark { content.append(Template.bookmark(level: level, - title: bookmark.title.escapedForHTML, + title: bookmark.title.escapedUnicodeHtmlString(), url: bookmark.url, isFavorite: bookmark.isFavorite)) } @@ -100,12 +100,6 @@ extension BookmarksExporter { fileprivate extension String { - var escapedForHTML: String { - self.replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - } - static func indent(by level: Int) -> String { return String(repeating: "\t", count: level) } diff --git a/DuckDuckGo/ErrorPage/ErrorPageHTMLTemplate.swift b/DuckDuckGo/ErrorPage/ErrorPageHTMLTemplate.swift new file mode 100644 index 0000000000..af9d04103e --- /dev/null +++ b/DuckDuckGo/ErrorPage/ErrorPageHTMLTemplate.swift @@ -0,0 +1,45 @@ +// +// ErrorPageHTMLTemplate.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 ContentScopeScripts +import WebKit + +struct ErrorPageHTMLTemplate { + + static var htmlTemplatePath: String { + guard let file = ContentScopeScripts.Bundle.path(forResource: "index", ofType: "html", inDirectory: "pages/errorpage") else { + assertionFailure("HTML template not found") + return "" + } + return file + } + + let error: WKError + let header: String + + func makeHTMLFromTemplate() -> String { + guard let html = try? String(contentsOfFile: Self.htmlTemplatePath) else { + assertionFailure("Should be able to load template") + return "" + } + return html.replacingOccurrences(of: "$ERROR_DESCRIPTION$", with: error.localizedDescription.escapedUnicodeHtmlString(), options: .literal) + .replacingOccurrences(of: "$HEADER$", with: header.escapedUnicodeHtmlString(), options: .literal) + } + +} diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index d98a50d6fd..6171229925 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -2889,18 +2889,6 @@ }, "Error message & code" : { - }, - "error.unknown" : { - "comment" : "Error page subtitle", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "An unknown error has occurred" - } - } - } }, "error.unknown.try.again" : { "comment" : "Generic error message on a dialog for when the cause is not known.", @@ -5914,6 +5902,42 @@ } } }, + "page.crash.header" : { + "comment" : "Error page heading text shown when a Web Page process had crashed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This webpage has crashed." + } + } + } + }, + "page.crash.message" : { + "comment" : "Error page message text shown when a Web Page process had crashed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try reloading the page or come back later." + } + } + } + }, + "page.error.header" : { + "comment" : "Error page heading text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo can’t load this page." + } + } + } + }, "passsword.management" : { "comment" : "Used as title for password management user interface", "extractionState" : "extracted_with_value", @@ -8645,7 +8669,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Oops!" + "value" : "Failed to open page" } } } @@ -9161,4 +9185,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index 9b7c307eea..adae427d70 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -65,7 +65,7 @@ final class MainViewController: NSViewController { tabBarViewController = TabBarViewController.create(tabCollectionViewModel: tabCollectionViewModel) navigationBarViewController = NavigationBarViewController.create(tabCollectionViewModel: tabCollectionViewModel, isBurner: isBurner) - browserTabViewController = BrowserTabViewController.create(tabCollectionViewModel: tabCollectionViewModel) + browserTabViewController = BrowserTabViewController(tabCollectionViewModel: tabCollectionViewModel, bookmarkManager: bookmarkManager) findInPageViewController = FindInPageViewController.create() fireViewController = FireViewController.create(tabCollectionViewModel: tabCollectionViewModel) bookmarksBarViewController = BookmarksBarViewController.create(tabCollectionViewModel: tabCollectionViewModel, bookmarkManager: bookmarkManager) diff --git a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift index 702fd0aa8b..b5846ee9ce 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift @@ -282,12 +282,18 @@ final class AddressBarButtonsViewController: NSViewController { guard view.window?.isPopUpWindow == false else { return } bookmarkButton.setAccessibilityIdentifier("Bookmarks Button") let hasEmptyAddressBar = textFieldValue?.isEmpty ?? true - var isUrlBookmarked = false - if let url = tabCollectionViewModel.selectedTabViewModel?.tab.content.url, - bookmarkManager.isUrlBookmarked(url: url) { - isUrlBookmarked = true + var showBookmarkButton: Bool { + guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel, + selectedTabViewModel.canBeBookmarked else { return false } + + var isUrlBookmarked = false + if let url = selectedTabViewModel.tab.content.url, + bookmarkManager.isUrlBookmarked(url: url) { + isUrlBookmarked = true + } + + return clearButton.isHidden && !hasEmptyAddressBar && (isMouseOverNavigationBar || bookmarkPopover?.isShown == true || isUrlBookmarked) } - let showBookmarkButton = clearButton.isHidden && !hasEmptyAddressBar && (isMouseOverNavigationBar || bookmarkPopover?.isShown == true || isUrlBookmarked) bookmarkButton.isHidden = !showBookmarkButton } @@ -612,15 +618,18 @@ final class AddressBarButtonsViewController: NSViewController { guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { updateBookmarkButtonImage() - updateBookmarkButtonVisibility() + updateButtons() return } - urlCancellable = selectedTabViewModel.tab.$content.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.stopAnimations() - self?.updateBookmarkButtonImage() - self?.updateBookmarkButtonVisibility() - } + urlCancellable = selectedTabViewModel.tab.$content + .combineLatest(selectedTabViewModel.tab.$error) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.stopAnimations() + self?.updateBookmarkButtonImage() + self?.updateButtons() + } } private func subscribeToPermissions() { @@ -690,8 +699,8 @@ final class AddressBarButtonsViewController: NSViewController { private func updatePermissionButtons() { permissionButtons.isHidden = isTextFieldEditorFirstResponder - || isAnyTrackerAnimationPlaying - || (tabCollectionViewModel.selectedTabViewModel?.errorViewState.isVisible ?? true) + || isAnyTrackerAnimationPlaying + || (tabCollectionViewModel.selectedTabViewModel?.isShowingErrorPage ?? true) defer { showOrHidePermissionPopoverIfNeeded() } @@ -750,6 +759,8 @@ final class AddressBarButtonsViewController: NSViewController { // Image button switch controllerMode { + case .browsing where selectedTabViewModel.isShowingErrorPage: + imageButton.image = Self.webImage case .browsing: imageButton.image = selectedTabViewModel.favicon case .editing(isUrl: true): @@ -774,12 +785,12 @@ final class AddressBarButtonsViewController: NSViewController { let isLocalUrl = selectedTabViewModel.tab.content.url?.isLocalURL ?? false // Privacy entry point button - privacyEntryPointButton.isHidden = isEditingMode || - isTextFieldEditorFirstResponder || - !isHypertextUrl || - selectedTabViewModel.errorViewState.isVisible || - isTextFieldValueText || - isLocalUrl + privacyEntryPointButton.isHidden = isEditingMode + || isTextFieldEditorFirstResponder + || !isHypertextUrl + || selectedTabViewModel.isShowingErrorPage + || isTextFieldValueText + || isLocalUrl imageButtonWrapper.isHidden = view.window?.isPopUpWindow == true || !privacyEntryPointButton.isHidden || isAnyTrackerAnimationPlaying diff --git a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift index 24d7ebe5fc..e60a26b90f 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift @@ -257,12 +257,16 @@ final class AddressBarViewController: NSViewController { } .store(in: &tabViewModelCancellables) - selectedTabViewModel.$isLoading - .sink { [weak self] isLoading in + selectedTabViewModel.$isLoading.combineLatest(selectedTabViewModel.tab.$error) + .debounce(for: 0.1, scheduler: RunLoop.main) + .sink { [weak self] isLoading, error in guard let progressIndicator = self?.progressIndicator else { return } if isLoading, - selectedTabViewModel.tab.content.url?.isDuckDuckGoSearch == false { + let url = selectedTabViewModel.tab.content.url, + [.http, .https].contains(url.navigationalScheme), + url.isDuckDuckGoSearch == false, + error == nil { progressIndicator.show(progress: selectedTabViewModel.progress, startTime: selectedTabViewModel.loadingStartTime) diff --git a/DuckDuckGo/NavigationBar/View/NavigationButtonMenuDelegate.swift b/DuckDuckGo/NavigationBar/View/NavigationButtonMenuDelegate.swift index 55b81c2954..ebf24cfbfb 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationButtonMenuDelegate.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationButtonMenuDelegate.swift @@ -44,22 +44,21 @@ extension NavigationButtonMenuDelegate: NSMenuDelegate { let listItems = listItems // Don't show menu if there is just the current item - if listItems.items.count == 0 || (listItems.items.count == 1 && listItems.currentIndex == 0) { return 0 } + if listItems.count == 1 { return 0 } - return listItems.items.count + return listItems.count } func menu(_ menu: NSMenu, update item: NSMenuItem, at index: Int, shouldCancel: Bool) -> Bool { - let (listItems, currentIndex) = self.listItems guard let listItem = listItems[safe: index] else { os_log("%s: Index out of bounds", type: .error, className) return true } - let listItemViewModel = WKBackForwardListItemViewModel(backForwardListItem: listItem, - faviconManagement: FaviconManager.shared, - historyCoordinating: HistoryCoordinator.shared, - isCurrentItem: index == currentIndex) + let listItemViewModel = BackForwardListItemViewModel(backForwardListItem: listItem, + faviconManagement: FaviconManager.shared, + historyCoordinating: HistoryCoordinator.shared, + isCurrentItem: index == 0) item.title = listItemViewModel.title item.image = listItemViewModel.image @@ -74,67 +73,25 @@ extension NavigationButtonMenuDelegate: NSMenuDelegate { @MainActor @objc func menuItemAction(_ sender: NSMenuItem) { let index = sender.tag - let (listItems, currentIndex) = self.listItems guard let listItem = listItems[safe: index] else { os_log("%s: Index out of bounds", type: .error, className) return } - - guard currentIndex != index else { - // current item selected: do nothing - return - } - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - os_log("%s: Selected tab view model is nil", type: .error, className) - return - } - - switch listItem { - case .backForwardListItem(let wkListItem): - selectedTabViewModel.tab.go(to: wkListItem) - case .goBackToCloseItem(parentTab:): - tabCollectionViewModel.selectedTabViewModel?.tab.goBack() - case .error: - break - } + tabCollectionViewModel.selectedTabViewModel?.tab.go(to: listItem) } - private var listItems: (items: [BackForwardListItem], currentIndex: Int?) { + private var listItems: [BackForwardListItem] { guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - os_log("%s: Selected tab view model is nil", type: .error, className) - return ([], nil) - } - - let backForwardList = selectedTabViewModel.tab.webView.backForwardList - let wkList = buttonType == .back ? backForwardList.backList.reversed() : backForwardList.forwardList - var list = wkList.map { BackForwardListItem.backForwardListItem($0) } - var currentIndex: Int? - - // Add closing with back button to the list - if list.count == 0, - let parentTab = selectedTabViewModel.tab.parentTab, - buttonType == .back { - list.insert(.goBackToCloseItem(parentTab: parentTab), at: 0) + assertionFailure("Selected tab view model is nil") + return [] } + guard let currentItem = selectedTabViewModel.tab.currentHistoryItem else { return [] } - // Add current item to the list - if let currentItem = selectedTabViewModel.tab.webView.backForwardList.currentItem { - list.insert(.backForwardListItem(currentItem), at: 0) - currentIndex = 0 - } - - // Add error to the list - if selectedTabViewModel.tab.error != nil { - if buttonType == .back { - list.insert(.error, at: 0) - currentIndex = 0 - } else { - list = [] - currentIndex = nil - } - } + let list = [currentItem] + (buttonType == .back + ? selectedTabViewModel.tab.backHistoryItems.reversed() + : selectedTabViewModel.tab.forwardHistoryItems) - return (list, currentIndex) + return list } } diff --git a/DuckDuckGo/NavigationBar/ViewModel/BackForwardListItem.swift b/DuckDuckGo/NavigationBar/ViewModel/BackForwardListItem.swift index af0c0289de..51fbfe4e81 100644 --- a/DuckDuckGo/NavigationBar/ViewModel/BackForwardListItem.swift +++ b/DuckDuckGo/NavigationBar/ViewModel/BackForwardListItem.swift @@ -16,22 +16,40 @@ // limitations under the License. // +import Navigation import WebKit -enum BackForwardListItem: Equatable { - case backForwardListItem(WKBackForwardListItem) - case goBackToCloseItem(parentTab: Tab) - case error +struct BackForwardListItem: Hashable { + + enum Kind: Hashable { + case url(URL) + case goBackToClose(URL?) + } + let kind: Kind + let title: String? + let identity: HistoryItemIdentity? var url: URL? { - switch self { - case .backForwardListItem(let item): - return item.url - case .goBackToCloseItem(parentTab: let tab): - return tab.content.url - case .error: - return nil + switch kind { + case .url(let url): return url + case .goBackToClose(let url): return url } } + init(kind: Kind, title: String?, identity: HistoryItemIdentity?) { + self.kind = kind + self.title = title + self.identity = identity + } + + init(_ wkItem: WKBackForwardListItem) { + self.init(kind: .url(wkItem.url), title: wkItem.tabTitle ?? wkItem.title, identity: wkItem.identity) + } + +} + +extension [BackForwardListItem] { + init(_ items: [WKBackForwardListItem]) { + self = items.map(BackForwardListItem.init) + } } diff --git a/DuckDuckGo/NavigationBar/ViewModel/WKBackForwardListItemViewModel.swift b/DuckDuckGo/NavigationBar/ViewModel/BackForwardListItemViewModel.swift similarity index 65% rename from DuckDuckGo/NavigationBar/ViewModel/WKBackForwardListItemViewModel.swift rename to DuckDuckGo/NavigationBar/ViewModel/BackForwardListItemViewModel.swift index 3eae6df4ae..2f3358c167 100644 --- a/DuckDuckGo/NavigationBar/ViewModel/WKBackForwardListItemViewModel.swift +++ b/DuckDuckGo/NavigationBar/ViewModel/BackForwardListItemViewModel.swift @@ -1,5 +1,5 @@ // -// WKBackForwardListItemViewModel.swift +// BackForwardListItemViewModel.swift // // Copyright © 2020 DuckDuckGo. All rights reserved. // @@ -17,9 +17,8 @@ // import Cocoa -import WebKit -final class WKBackForwardListItemViewModel { +final class BackForwardListItemViewModel { private let backForwardListItem: BackForwardListItem private let faviconManagement: FaviconManagement @@ -37,42 +36,33 @@ final class WKBackForwardListItemViewModel { } var title: String { - switch backForwardListItem { - case .backForwardListItem(let item): - if item.url == .newtab { + switch backForwardListItem.kind { + case .url(let url): + if url == .newtab { return UserText.tabHomeTitle } - var title = item.title + var title = backForwardListItem.title if title == nil || (title?.isEmpty ?? false) { - title = historyCoordinating.title(for: item.url) + title = historyCoordinating.title(for: url) } - return title ?? - item.url.host ?? - item.url.absoluteString + return (title ?? url.host ?? url.absoluteString).truncated(length: MainMenu.Constants.maxTitleLength) - case .goBackToCloseItem(parentTab: let tab): - if let title = tab.title, - !title.isEmpty { - return String(format: UserText.closeAndReturnToParentFormat, title) + case .goBackToClose(let url): + if let title = backForwardListItem.title ?? url?.absoluteString, !title.isEmpty { + return String(format: UserText.closeAndReturnToParentFormat, title.truncated(length: MainMenu.Constants.maxTitleLength)) } else { return UserText.closeAndReturnToParent } - case .error: - return UserText.tabErrorTitle } } @MainActor(unsafe) var image: NSImage? { - if case .error = backForwardListItem { - return nil - } - if backForwardListItem.url == .newtab { - return NSImage(named: "HomeFavicon") + return .homeFavicon } if backForwardListItem.url?.isDuckPlayer == true { @@ -85,23 +75,11 @@ final class WKBackForwardListItemViewModel { return image } - return NSImage(named: "DefaultFavicon") + return .globeMulticolor16 } var state: NSControl.StateValue { - if case .goBackToCloseItem = backForwardListItem { - return .off - } - - return isCurrentItem ? .on : .off - } - - var isGoBackToCloseItem: Bool { - if case .goBackToCloseItem = backForwardListItem { - return true - } - - return false + isCurrentItem ? .on : .off } } diff --git a/DuckDuckGo/Sharing/SharingMenu.swift b/DuckDuckGo/Sharing/SharingMenu.swift index c3a6c1ec12..50c5808e4a 100644 --- a/DuckDuckGo/Sharing/SharingMenu.swift +++ b/DuckDuckGo/Sharing/SharingMenu.swift @@ -48,6 +48,7 @@ final class SharingMenu: NSMenu { private func sharingData() -> SharingData? { guard let tabViewModel = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel.selectedTabViewModel, tabViewModel.canReload, + !tabViewModel.isShowingErrorPage, let url = tabViewModel.tab.content.url else { return nil } let sharingData = DuckPlayer.shared.sharingData(for: tabViewModel.title, url: url) ?? (tabViewModel.title, url) diff --git a/DuckDuckGo/Tab/Model/Tab+Navigation.swift b/DuckDuckGo/Tab/Model/Tab+Navigation.swift index daaae97f59..3df38461d6 100644 --- a/DuckDuckGo/Tab/Model/Tab+Navigation.swift +++ b/DuckDuckGo/Tab/Model/Tab+Navigation.swift @@ -42,6 +42,10 @@ extension Tab: NavigationResponder { navigationDelegate.setResponders( .weak(nullable: self.navigationHotkeyHandler), + // redirect to SERP for non-valid domains entered by user + // should be before `self` to avoid Tab presenting an error screen + .weak(nullable: self.searchForNonexistentDomains), + .weak(self), // Duck Player overlay navigations handling @@ -65,9 +69,6 @@ extension Tab: NavigationResponder { // add extra headers to SERP requests .struct(SerpHeadersNavigationResponder()), - // redirect to SERP for non-valid domains entered by user - .weak(nullable: self.searchForNonexistentDomains), - // ensure Content Blocking Rules are applied before navigation .weak(nullable: self.contentBlockingAndSurrogates), // update click-to-load state diff --git a/DuckDuckGo/Tab/Model/Tab.swift b/DuckDuckGo/Tab/Model/Tab.swift index f2c35a5a18..57cad4d4db 100644 --- a/DuckDuckGo/Tab/Model/Tab.swift +++ b/DuckDuckGo/Tab/Model/Tab.swift @@ -440,6 +440,7 @@ protocol NewWindowPolicyDecisionMaker { isTabPinned: { tabGetter().map { tab in pinnedTabsManager.isTabPinned(tab) } ?? false }, isTabBurner: burnerMode.isBurner, contentPublisher: _content.projectedValue.eraseToAnyPublisher(), + setContent: { tabGetter()?.setContent($0) }, titlePublisher: _title.projectedValue.eraseToAnyPublisher(), userScriptsPublisher: userScriptsPublisher, inheritedAttribution: parentTab?.adClickAttribution?.currentAttributionState, @@ -598,7 +599,6 @@ protocol NewWindowPolicyDecisionMaker { let webViewDidReceiveRedirectPublisher = PassthroughSubject() let webViewDidCommitNavigationPublisher = PassthroughSubject() let webViewDidFinishNavigationPublisher = PassthroughSubject() - let webViewDidFailNavigationPublisher = PassthroughSubject() // MARK: - Properties @@ -616,7 +616,6 @@ protocol NewWindowPolicyDecisionMaker { webView.stopAllMedia(shouldStopLoading: false) } handleFavicon(oldValue: oldValue) - invalidateInteractionStateData() if navigationDelegate.currentNavigation == nil { updateCanGoBackForward(withCurrentNavigation: nil) } @@ -674,6 +673,12 @@ protocol NewWindowPolicyDecisionMaker { @Published var title: String? private func updateTitle() { + if let error { + if error.code != .webContentProcessTerminated { + self.title = nil + } + return + } var title = webView.title?.trimmingWhitespace() if title?.isEmpty ?? true { title = webView.url?.host?.droppingWwwPrefix() @@ -682,14 +687,16 @@ protocol NewWindowPolicyDecisionMaker { if title != self.title { self.title = title } + + if let wkBackForwardListItem = webView.backForwardList.currentItem, + content.urlForWebView == wkBackForwardListItem.url { + wkBackForwardListItem.tabTitle = title + } } @PublishedAfter var error: WKError? { didSet { - if error == nil || error?.isFrameLoadInterrupted == true || error?.isNavigationCancelled == true { - return - } - webView.stopAllMediaPlayback() + updateTitle() } } let permissions: PermissionModel @@ -762,6 +769,23 @@ protocol NewWindowPolicyDecisionMaker { @Published private(set) var canGoBack: Bool = false @Published private(set) var canReload: Bool = false + @MainActor + var backHistoryItems: [BackForwardListItem] { + [BackForwardListItem](webView.backForwardList.backList) + + (canBeClosedWithBack ? [BackForwardListItem(kind: .goBackToClose(parentTab?.url), title: parentTab?.title, identity: nil)] : []) + } + @MainActor + var currentHistoryItem: BackForwardListItem? { + webView.backForwardList.currentItem.map(BackForwardListItem.init) + ?? (content.url ?? navigationDelegate.currentNavigation?.url).map { url in + BackForwardListItem(kind: .url(url), title: webView.title ?? title, identity: nil) + } + } + @MainActor + var forwardHistoryItems: [BackForwardListItem] { + [BackForwardListItem](webView.backForwardList.forwardList) + } + private func updateCanGoBackForward() { updateCanGoBackForward(withCurrentNavigation: navigationDelegate.currentNavigation) } @@ -780,8 +804,8 @@ protocol NewWindowPolicyDecisionMaker { return } - let canGoBack = webView.canGoBack || self.error != nil - let canGoForward = webView.canGoForward && self.error == nil + let canGoBack = webView.canGoBack + let canGoForward = webView.canGoForward let canReload = self.content.userEditableUrl != nil if canGoBack != self.canGoBack { @@ -805,10 +829,6 @@ protocol NewWindowPolicyDecisionMaker { return nil } - guard error == nil else { - return webView.navigator()?.reload(withExpectedNavigationType: .reload) - } - userInteractionDialog = nil return webView.navigator()?.goBack(withExpectedNavigationType: .backForward(distance: -1)) } @@ -817,14 +837,62 @@ protocol NewWindowPolicyDecisionMaker { @discardableResult func goForward() -> ExpectedNavigation? { guard canGoForward else { return nil } + + userInteractionDialog = nil return webView.navigator()?.goForward(withExpectedNavigationType: .backForward(distance: 1)) } - func go(to item: WKBackForwardListItem) { - webView.go(to: item) + @MainActor + @discardableResult + func go(to item: BackForwardListItem) -> ExpectedNavigation? { + userInteractionDialog = nil + + switch item.kind { + case .goBackToClose: + delegate?.closeTab(self) + return nil + + case .url: break + } + + var backForwardNavigation: (distance: Int, item: WKBackForwardListItem)? { + guard let identity = item.identity else { return nil } + + let backForwardList = webView.backForwardList + if let backItem = backForwardList.backItem, backItem.identity == identity { + return (-1, backItem) + } else if let forwardItem = backForwardList.forwardItem, forwardItem.identity == identity { + return (1, forwardItem) + } else if backForwardList.currentItem?.identity == identity { + return nil + } + + let forwardList = backForwardList.forwardList + if let forwardIndex = forwardList.firstIndex(where: { $0.identity == identity }) { + return (forwardIndex + 1, forwardList[forwardIndex]) // going forward, adding 1 to zero based index + } + + let backList = backForwardList.backList + if let backIndex = backList.lastIndex(where: { $0.identity == identity }) { + return (-(backList.count - backIndex), backList[backIndex]) // item is in _reversed_ backList + } + + return nil + + } + + guard let backForwardNavigation else { + os_log(.error, "item `\(item.title ?? "") – \(item.url?.absoluteString ?? "")` is not in the backForwardList") + return nil + } + + return webView.navigator()?.go(to: backForwardNavigation.item, + withExpectedNavigationType: .backForward(distance: backForwardNavigation.distance)) } func openHomePage() { + userInteractionDialog = nil + if startupPreferences.launchToCustomHomePage, let customURL = URL(string: startupPreferences.formattedCustomHomePageURL) { webView.load(URLRequest(url: customURL)) @@ -834,24 +902,35 @@ protocol NewWindowPolicyDecisionMaker { } func startOnboarding() { + userInteractionDialog = nil + webView.load(URLRequest(url: .welcome)) } - func reload() { + @MainActor(unsafe) + @discardableResult + func reload() -> ExpectedNavigation? { userInteractionDialog = nil // In the case of an error only reload web URLs to prevent uxss attacks via redirecting to javascript:// - if let error = error, let failingUrl = error.failingUrl, failingUrl.isHttp || failingUrl.isHttps { - webView.load(URLRequest(url: failingUrl, cachePolicy: .reloadIgnoringLocalCacheData)) - return + if let error = error, + let failingUrl = error.failingUrl ?? content.urlForWebView, + failingUrl.isHttp || failingUrl.isHttps, + // navigate in-place to preserve back-forward history + // launch navigation using javascript: URL navigation to prevent WebView from + // interpreting the action as user-initiated link navigation causing a new tab opening when Cmd is pressed + let redirectUrl = URL(string: "javascript:location.replace('\(failingUrl.absoluteString.escapedJavaScriptString())')") { + + webView.load(URLRequest(url: redirectUrl)) + return nil } if webView.url == nil, content.isUrl { self.content = content.forceReload() // load from cache or interactionStateData when called by lazy loader - reloadIfNeeded(shouldLoadInBackground: true) + return reloadIfNeeded(shouldLoadInBackground: true) } else { - webView.reload() + return webView.navigator(distributedNavigationDelegate: navigationDelegate).reload(withExpectedNavigationType: .reload) } } @@ -872,7 +951,11 @@ protocol NewWindowPolicyDecisionMaker { let forceReload = (url.absoluteString == content.userEnteredValue) ? shouldLoadInBackground : (source == .reload) if forceReload || shouldReload(url, shouldLoadInBackground: shouldLoadInBackground) { + if webView.url == url, webView.backForwardList.currentItem?.url == url, !webView.isLoading { + return reload() + } if restoreInteractionStateDataIfNeeded() { return nil /* session restored */ } + invalidateInteractionStateData() if url.isFileURL { return webView.navigator(distributedNavigationDelegate: navigationDelegate) @@ -883,7 +966,6 @@ protocol NewWindowPolicyDecisionMaker { if #available(macOS 12.0, *), content.isUserEnteredUrl { request.attribution = .user } - invalidateInteractionStateData() return webView.navigator(distributedNavigationDelegate: navigationDelegate) .load(request, withExpectedNavigationType: source.navigationType) @@ -897,7 +979,7 @@ protocol NewWindowPolicyDecisionMaker { guard url.isValid, webView.superview != nil || shouldLoadInBackground, // don‘t reload when already loaded - webView.url != url else { return false } + webView.url != url || error != nil else { return false } // if content not loaded inspect error switch error { @@ -981,9 +1063,13 @@ protocol NewWindowPolicyDecisionMaker { } }.store(in: &webViewCancellables) - webView.observe(\.url) { [weak self] _, _ in - self?.handleUrlDidChange() - }.store(in: &webViewCancellables) + webView.publisher(for: \.url) + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleUrlDidChange() + }.store(in: &webViewCancellables) + webView.observe(\.title) { [weak self] _, _ in self?.updateTitle() }.store(in: &webViewCancellables) @@ -1039,7 +1125,7 @@ protocol NewWindowPolicyDecisionMaker { @MainActor(unsafe) private func handleFavicon(oldValue: TabContent? = nil) { - guard content.isUrl, let url = content.urlForWebView else { + guard content.isUrl, let url = content.urlForWebView, error == nil else { favicon = nil return } @@ -1092,6 +1178,7 @@ extension Tab: FaviconUserScriptDelegate { func faviconUserScript(_ faviconUserScript: FaviconUserScript, didFindFaviconLinks faviconLinks: [FaviconUserScript.FaviconLink], for documentUrl: URL) { + guard documentUrl != .error else { return } faviconManagement.handleFaviconLinks(faviconLinks, documentUrl: documentUrl) { favicon in guard documentUrl == self.content.url, let favicon = favicon else { return @@ -1192,7 +1279,10 @@ extension Tab/*: NavigationResponder*/ { // to be moved to Tab+Navigation.swift userInteractionDialog = nil // Unnecessary assignment triggers publishing - if error != nil { error = nil } + if error != nil, + navigation.navigationAction.navigationType != .alternateHtmlLoad { // error page navigation + error = nil + } invalidateInteractionStateData() } @@ -1212,16 +1302,47 @@ extension Tab/*: NavigationResponder*/ { // to be moved to Tab+Navigation.swift @MainActor func navigation(_ navigation: Navigation, didFailWith error: WKError) { - if navigation.isCurrent { + invalidateInteractionStateData() + + let url = error.failingUrl ?? navigation.url + if navigation.isCurrent, + !error.isFrameLoadInterrupted, !error.isNavigationCancelled, + // don‘t show an error page if the error was already handled + // (by SearchNonexistentDomainNavigationResponder) or another navigation was triggered by `setContent` + self.content.urlForWebView == url { + self.error = error + // when already displaying the error page and reload navigation fails again: don‘t navigate, just update page HTML + let shouldPerformAlternateNavigation = navigation.url != webView.url || navigation.navigationAction.targetFrame?.url != .error + loadErrorHTML(error, header: UserText.errorPageHeader, forUnreachableURL: url, alternate: shouldPerformAlternateNavigation) } - - invalidateInteractionStateData() - webViewDidFailNavigationPublisher.send() } + @MainActor func webContentProcessDidTerminate(with reason: WKProcessTerminationReason?) { - Pixel.fire(.debug(event: .webKitDidTerminate, error: NSError(domain: "WKProcessTerminated", code: reason?.rawValue ?? -1))) + let error = WKError(.webContentProcessTerminated, userInfo: [ + WKProcessTerminationReason.userInfoKey: reason?.rawValue ?? -1, + NSLocalizedDescriptionKey: UserText.webProcessCrashPageMessage, + ]) + + if case.url(let url, _, _) = content { + self.error = error + + loadErrorHTML(error, header: UserText.webProcessCrashPageHeader, forUnreachableURL: url, alternate: true) + } + + Pixel.fire(.debug(event: .webKitDidTerminate, error: error)) + } + + @MainActor + private func loadErrorHTML(_ error: WKError, header: String, forUnreachableURL url: URL, alternate: Bool) { + let html = ErrorPageHTMLTemplate(error: error, header: header).makeHTMLFromTemplate() + if alternate { + webView.loadAlternateHTML(html, baseURL: .error, forUnreachableURL: url) + } else { + // this should be updated using an error page update script call when (if) we have a dynamic error page content implemented + webView.setDocumentHtml(html) + } } } diff --git a/DuckDuckGo/Tab/Navigation/SearchNonexistentDomainNavigationResponder.swift b/DuckDuckGo/Tab/Navigation/SearchNonexistentDomainNavigationResponder.swift index b157283f5d..713f737e12 100644 --- a/DuckDuckGo/Tab/Navigation/SearchNonexistentDomainNavigationResponder.swift +++ b/DuckDuckGo/Tab/Navigation/SearchNonexistentDomainNavigationResponder.swift @@ -24,11 +24,13 @@ import Navigation final class SearchNonexistentDomainNavigationResponder { private let tld: TLD + private let setContent: (Tab.TabContent) -> Void private var lastUserEnteredValue: String? private var cancellable: AnyCancellable? - init(tld: TLD, contentPublisher: some Publisher) { + init(tld: TLD, contentPublisher: some Publisher, setContent: @escaping (Tab.TabContent) -> Void) { self.tld = tld + self.setContent = setContent cancellable = contentPublisher.sink { [weak self] tabContent in if case .url(_, credential: .none, source: .userEntered(let userEnteredValue)) = tabContent { @@ -65,7 +67,7 @@ extension SearchNonexistentDomainNavigationResponder: NavigationResponder { // redirect to SERP for non-valid domains entered by user // https://app.asana.com/0/1177771139624306/1204041033469842/f - navigation.navigationAction.targetFrame?.webView?.load(URLRequest(url: url)) + setContent(.url(url, source: .userEntered(lastUserEnteredValue))) } } diff --git a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift index 75d2050c92..dd6f107694 100644 --- a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift +++ b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift @@ -77,6 +77,7 @@ typealias TabExtensionsBuilderArguments = ( isTabPinned: () -> Bool, isTabBurner: Bool, contentPublisher: AnyPublisher, + setContent: (Tab.TabContent) -> Void, titlePublisher: AnyPublisher, userScriptsPublisher: AnyPublisher, inheritedAttribution: AdClickAttributionLogic.State?, @@ -161,7 +162,7 @@ extension TabExtensionsBuilder { isBurner: args.isTabBurner) } add { - SearchNonexistentDomainNavigationResponder(tld: dependencies.privacyFeatures.contentBlocking.tld, contentPublisher: args.contentPublisher) + SearchNonexistentDomainNavigationResponder(tld: dependencies.privacyFeatures.contentBlocking.tld, contentPublisher: args.contentPublisher, setContent: args.setContent) } add { HistoryTabExtension(isBurner: args.isTabBurner, diff --git a/DuckDuckGo/Tab/TabLazyLoader/LazyLoadable.swift b/DuckDuckGo/Tab/TabLazyLoader/LazyLoadable.swift index d690530c46..20415c11e6 100644 --- a/DuckDuckGo/Tab/TabLazyLoader/LazyLoadable.swift +++ b/DuckDuckGo/Tab/TabLazyLoader/LazyLoadable.swift @@ -16,8 +16,9 @@ // limitations under the License. // -import Foundation import Combine +import Foundation +import Navigation protocol LazyLoadable: AnyObject, Identifiable { @@ -28,7 +29,8 @@ protocol LazyLoadable: AnyObject, Identifiable { var isLazyLoadingInProgress: Bool { get set } var loadingFinishedPublisher: AnyPublisher { get } - func reload() + @discardableResult + func reload() -> ExpectedNavigation? func isNewer(than other: Self) -> Bool } @@ -38,7 +40,7 @@ extension Tab: LazyLoadable { var url: URL? { content.url } var loadingFinishedPublisher: AnyPublisher { - Publishers.Merge(webViewDidFinishNavigationPublisher, webViewDidFailNavigationPublisher) + webViewDidFinishNavigationPublisher .prefix(1) .map { self } .eraseToAnyPublisher() diff --git a/DuckDuckGo/Tab/View/Base.lproj/BrowserTab.storyboard b/DuckDuckGo/Tab/View/Base.lproj/BrowserTab.storyboard deleted file mode 100644 index b21cbc5cda..0000000000 --- a/DuckDuckGo/Tab/View/Base.lproj/BrowserTab.storyboard +++ /dev/null @@ -1,121 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 3854e0068e..69a680f172 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -24,11 +24,11 @@ import SwiftUI import BrowserServicesKit final class BrowserTabViewController: NSViewController { - @IBOutlet var errorView: NSView! - @IBOutlet var homePageView: NSView! - @IBOutlet var errorMessageLabel: NSTextField! - @IBOutlet var hoverLabel: NSTextField! - @IBOutlet var hoverLabelContainer: NSView! + + private lazy var homePageView = NSView() + private lazy var hoverLabel = NSTextField(string: URL.duckDuckGo.absoluteString) + private lazy var hoverLabelContainer = ColorView(frame: .zero, backgroundColor: .browserTabBackground, borderWidth: 0) + private weak var webView: WebView? private weak var webViewContainer: NSView? private weak var webViewSnapshot: NSView? @@ -36,11 +36,11 @@ final class BrowserTabViewController: NSViewController { var tabViewModel: TabViewModel? private let tabCollectionViewModel: TabCollectionViewModel + private let bookmarkManager: BookmarkManager private var tabContentCancellable: AnyCancellable? private var userDialogsCancellable: AnyCancellable? private var activeUserDialogCancellable: Cancellable? - private var errorViewStateCancellable: AnyCancellable? private var hoverLinkCancellable: AnyCancellable? private var pinnedTabsDelegatesCancellable: AnyCancellable? private var keyWindowSelectedTabCancellable: AnyCancellable? @@ -53,32 +53,62 @@ final class BrowserTabViewController: NSViewController { private var transientTabContentViewController: NSViewController? - static func create(tabCollectionViewModel: TabCollectionViewModel) -> BrowserTabViewController { - NSStoryboard(name: "BrowserTab", bundle: nil).instantiateInitialController { coder in - self.init(coder: coder, tabCollectionViewModel: tabCollectionViewModel) - }! - } - required init?(coder: NSCoder) { fatalError("BrowserTabViewController: Bad initializer") } - init?(coder: NSCoder, tabCollectionViewModel: TabCollectionViewModel) { + init(tabCollectionViewModel: TabCollectionViewModel, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { self.tabCollectionViewModel = tabCollectionViewModel + self.bookmarkManager = bookmarkManager - super.init(coder: coder) + super.init(nibName: nil, bundle: nil) } - override func viewDidLoad() { - super.viewDidLoad() + override func loadView() { + view = BrowserTabView(frame: .zero, backgroundColor: .browserTabBackground) - let homePageViewController = HomePageViewController(tabCollectionViewModel: tabCollectionViewModel, bookmarkManager: LocalBookmarkManager.shared) + homePageView.translatesAutoresizingMaskIntoConstraints = false + view.addAndLayout(homePageView) + + hoverLabelContainer.cornerRadius = 4 + view.addSubview(hoverLabelContainer) + + hoverLabel.focusRingType = .none + hoverLabel.translatesAutoresizingMaskIntoConstraints = false + hoverLabel.font = .systemFont(ofSize: 13) + hoverLabel.drawsBackground = false + hoverLabel.isEditable = false + hoverLabel.isBordered = false + hoverLabel.lineBreakMode = .byClipping + hoverLabel.textColor = .labelColor + hoverLabelContainer.addSubview(hoverLabel) + + setupLayout() + + let homePageViewController = HomePageViewController(tabCollectionViewModel: tabCollectionViewModel, bookmarkManager: bookmarkManager) self.addAndLayoutChild(homePageViewController, into: homePageView) + } + + private func setupLayout() { + hoverLabelContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: -2).isActive = true + view.bottomAnchor.constraint(equalTo: hoverLabelContainer.bottomAnchor, constant: -4).isActive = true + + hoverLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) + hoverLabel.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) + hoverLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + hoverLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + hoverLabelContainer.bottomAnchor.constraint(equalTo: hoverLabel.bottomAnchor, constant: 10).isActive = true + hoverLabel.leadingAnchor.constraint(equalTo: hoverLabelContainer.leadingAnchor, constant: 12).isActive = true + hoverLabelContainer.trailingAnchor.constraint(equalTo: hoverLabel.trailingAnchor, constant: 8).isActive = true + hoverLabel.topAnchor.constraint(equalTo: hoverLabelContainer.topAnchor, constant: 6).isActive = true + } + + override func viewDidLoad() { + super.viewDidLoad() hoverLabelContainer.alphaValue = 0 subscribeToTabs() subscribeToSelectedTabViewModel() - subscribeToErrorViewState() view.registerForDraggedTypes([.URL, .fileURL]) } @@ -217,7 +247,6 @@ final class BrowserTabViewController: NSViewController { generateNativePreviewIfNeeded() self.tabViewModel = selectedTabViewModel self.showTabContent(of: selectedTabViewModel) - self.subscribeToErrorViewState() self.subscribeToTabContent(of: selectedTabViewModel) self.subscribeToHoveredLink(of: selectedTabViewModel) self.subscribeToUserDialogs(of: selectedTabViewModel) @@ -294,10 +323,8 @@ final class BrowserTabViewController: NSViewController { private func addWebViewToViewHierarchy(_ webView: WebView, tab: Tab) { let container = WebViewContainerView(tab: tab, webView: webView, frame: view.bounds) self.webViewContainer = container - view.addSubview(container) - // Make sure link preview (tooltip shown in the bottom-left) is on top - view.addSubview(hoverLabelContainer) + view.addSubview(container, positioned: .below, relativeTo: hoverLabelContainer) } private func changeWebView(tabViewModel: TabViewModel?) { @@ -355,11 +382,10 @@ final class BrowserTabViewController: NSViewController { // For URL tabs, we only want to show tab content (webView) when webView starts // navigation or when another navigation-related event happens. // We take the first such event and move forward. - return Publishers.Merge5( + return Publishers.Merge4( tabViewModel.tab.webViewDidStartNavigationPublisher, tabViewModel.tab.webViewDidReceiveRedirectPublisher, tabViewModel.tab.webViewDidCommitNavigationPublisher, - tabViewModel.tab.webViewDidFailNavigationPublisher, tabViewModel.tab.webViewDidReceiveUserInteractiveChallengePublisher ) .prefix(1) @@ -387,19 +413,15 @@ final class BrowserTabViewController: NSViewController { } } - private func subscribeToErrorViewState() { - errorViewStateCancellable = tabViewModel?.$errorViewState.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.displayErrorView( - self?.tabViewModel?.errorViewState.isVisible ?? false, - message: self?.tabViewModel?.errorViewState.message ?? UserText.unknownErrorMessage - ) - } - } - func subscribeToHoveredLink(of tabViewModel: TabViewModel?) { hoverLinkCancellable = tabViewModel?.tab.hoveredLinkPublisher.sink { [weak self] in self?.scheduleHoverLabelUpdatesForUrl($0) } +#if DEBUG + if case .xcPreviews = NSApp.runType { + self.scheduleHoverLabelUpdatesForUrl(.duckDuckGo) + } +#endif } func makeWebViewFirstResponder() { @@ -427,13 +449,6 @@ final class BrowserTabViewController: NSViewController { } } - private func displayErrorView(_ shown: Bool, message: String) { - errorMessageLabel.stringValue = message - errorView.isHidden = !shown - webView?.isHidden = shown - homePageView.isHidden = shown - } - func openNewTab(with content: Tab.TabContent) { guard tabCollectionViewModel.selectDisplayableTabIfPresent(content) == false else { return @@ -543,7 +558,7 @@ final class BrowserTabViewController: NSViewController { } func generateNativePreviewIfNeeded() { - guard let tabViewModel = tabViewModel, !tabViewModel.tab.content.isUrl, !tabViewModel.errorViewState.isVisible else { + guard let tabViewModel = tabViewModel, !tabViewModel.tab.content.isUrl, !tabViewModel.isShowingErrorPage else { return } @@ -1105,3 +1120,8 @@ fileprivate extension NSView { } } + +@available(macOS 14.0, *) +#Preview { + BrowserTabViewController(tabCollectionViewModel: TabCollectionViewModel(tabCollection: TabCollection(tabs: [.init(content: .url(.duckDuckGo, source: .ui))]))) +} diff --git a/DuckDuckGo/Tab/ViewModel/TabViewModel.swift b/DuckDuckGo/Tab/ViewModel/TabViewModel.swift index 972adad69a..91803f30ac 100644 --- a/DuckDuckGo/Tab/ViewModel/TabViewModel.swift +++ b/DuckDuckGo/Tab/ViewModel/TabViewModel.swift @@ -48,15 +48,8 @@ final class TabViewModel { } @Published var progress: Double = 0.0 - struct ErrorViewState { - var isVisible: Bool = false - var message: String? - } - @Published var errorViewState = ErrorViewState() { - didSet { - updateTitle() - updateFavicon() - } + var isShowingErrorPage: Bool { + tab.error != nil } @Published var autofillDataToSave: AutofillData? @@ -76,11 +69,11 @@ final class TabViewModel { @Published private(set) var permissionAuthorizationQuery: PermissionAuthorizationQuery? var canPrint: Bool { - self.canReload && tab.webView.canPrint + !isShowingErrorPage && canReload && tab.webView.canPrint } var canSaveContent: Bool { - self.canReload && !tab.webView.isInFullScreenMode + !isShowingErrorPage && canReload && !tab.webView.isInFullScreenMode } init(tab: Tab, appearancePreferences: AppearancePreferences = .shared) { @@ -169,18 +162,10 @@ final class TabViewModel { } private func subscribeToTabError() { - tab.$error - .map { error -> ErrorViewState in - - if let error = error, !error.isFrameLoadInterrupted, !error.isNavigationCancelled { - // don‘t show error for interrupted load like downloads and for cancelled loads - return .init(isVisible: true, message: error.localizedDescription) - } else { - return .init(isVisible: false, message: nil) - } - } - .assign(to: \.errorViewState, onWeaklyHeld: self) - .store(in: &cancellables) + tab.$error.sink { [weak self] _ in + self?.updateTitle() + self?.updateFavicon() + }.store(in: &cancellables) } private func subscribeToPermissions() { @@ -209,7 +194,7 @@ final class TabViewModel { } private func updateCanBeBookmarked() { - canBeBookmarked = tab.content.url ?? .blankPage != .blankPage + canBeBookmarked = !isShowingErrorPage && (tab.content.url ?? .blankPage) != .blankPage } private var tabURL: URL? { @@ -221,14 +206,6 @@ final class TabViewModel { } func updateAddressBarStrings() { - guard !errorViewState.isVisible else { - let failingUrl = tab.error?.failingUrl - let failingUrlHost = failingUrl?.host?.droppingWwwPrefix() ?? "" - addressBarString = failingUrl?.absoluteString ?? "" - passiveAddressBarString = appearancePreferences.showFullURL ? addressBarString : failingUrlHost - return - } - guard tab.content.isUrl, let url = tabURL else { addressBarString = "" passiveAddressBarString = "" @@ -273,13 +250,12 @@ final class TabViewModel { } } - private func updateTitle() { - guard !errorViewState.isVisible else { - title = UserText.tabErrorTitle - return - } - + private func updateTitle() { // swiftlint:disable:this cyclomatic_complexity + let title: String switch tab.content { + // keep an old tab title for web page terminated page, display "Failed to open page" for loading errors + case _ where isShowingErrorPage && (tab.error?.code != .webContentProcessTerminated || tab.title == nil): + title = UserText.tabErrorTitle case .dataBrokerProtection: title = UserText.tabDataBrokerProtectionTitle case .settings: @@ -295,20 +271,22 @@ final class TabViewModel { case .onboarding: title = UserText.tabOnboardingTitle case .url, .none, .subscription: - if let title = tab.title?.trimmingWhitespace(), - !title.isEmpty { - self.title = title + if let tabTitle = tab.title?.trimmingWhitespace(), !tabTitle.isEmpty { + title = tabTitle } else if let host = tab.url?.host?.droppingWwwPrefix() { - self.title = host + title = host } else { - self.title = addressBarString + title = addressBarString } } + if self.title != title { + self.title = title + } } private func updateFavicon() { - guard !errorViewState.isVisible else { - favicon = nil + guard !isShowingErrorPage else { + favicon = .alertCircleColor16 return } diff --git a/DuckDuckGo/TabPreview/TabPreviewViewController.swift b/DuckDuckGo/TabPreview/TabPreviewViewController.swift index 264fee1153..67c846ba7e 100644 --- a/DuckDuckGo/TabPreview/TabPreviewViewController.swift +++ b/DuckDuckGo/TabPreview/TabPreviewViewController.swift @@ -57,7 +57,7 @@ extension TabPreviewViewController { urlTextField.stringValue = "" } - if !isSelected, !tabViewModel.errorViewState.isVisible, let snapshot = tabViewModel.tab.tabSnapshot { + if !isSelected, !tabViewModel.isShowingErrorPage, let snapshot = tabViewModel.tab.tabSnapshot { snapshotImageView.image = snapshot snapshotImageViewHeightConstraint.constant = getHeight(for: tabViewModel.tab.tabSnapshot) } else { diff --git a/DuckDuckGo/YoutubePlayer/DuckURLSchemeHandler.swift b/DuckDuckGo/YoutubePlayer/DuckURLSchemeHandler.swift index fe35c9f578..85565b322d 100644 --- a/DuckDuckGo/YoutubePlayer/DuckURLSchemeHandler.swift +++ b/DuckDuckGo/YoutubePlayer/DuckURLSchemeHandler.swift @@ -21,6 +21,27 @@ import WebKit final class DuckURLSchemeHandler: NSObject, WKURLSchemeHandler { + static let emptyHtml = """ + + + + + + + """ + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { guard let requestURL = webView.url ?? urlSchemeTask.request.url else { assertionFailure("No URL for Private Player scheme handler") @@ -29,7 +50,7 @@ final class DuckURLSchemeHandler: NSObject, WKURLSchemeHandler { guard requestURL.isDuckPlayer else { // return empty page for native UI pages navigations (like the Home page or Settings) if the request is not for the Duck Player - let data = "".utf8data + let data = Self.emptyHtml.utf8data let response = URLResponse(url: requestURL, mimeType: "text/html", diff --git a/IntegrationTests/Common/IntegrationTestsBridging.h b/IntegrationTests/Common/IntegrationTestsBridging.h new file mode 100644 index 0000000000..b2f140218b --- /dev/null +++ b/IntegrationTests/Common/IntegrationTestsBridging.h @@ -0,0 +1,21 @@ +// +// IntegrationTestsBridging.h +// +// Copyright © 2021 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 "Bridging.h" + +#import "WKURLSchemeTask+Private.h" diff --git a/IntegrationTests/Tab/ErrorPageTests.swift b/IntegrationTests/Tab/ErrorPageTests.swift new file mode 100644 index 0000000000..3b8e4134ad --- /dev/null +++ b/IntegrationTests/Tab/ErrorPageTests.swift @@ -0,0 +1,930 @@ +// +// ErrorPageTests.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 Combine +import Common +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +@available(macOS 12.0, *) +@MainActor +class ErrorPageTests: XCTestCase { + + var window: NSWindow! + + var mainViewController: MainViewController { + (window.contentViewController as! MainViewController) + } + + var tabViewModel: TabViewModel { + mainViewController.browserTabViewController.tabViewModel! + } + + var webViewConfiguration: WKWebViewConfiguration! + var schemeHandler: TestSchemeHandler! + + static let pageTitle = "test page" + static let testHtml = "\(pageTitle)test" + static let alternativeTitle = "alternative page" + static let alternativeHtml = "\(alternativeTitle)alternative body" + + static let sessionStateData = Data.sessionRestorationMagic + """ + + + + + IsAppInitiated + + SessionHistory + + SessionHistoryVersion + 1 + SessionHistoryEntries + + + SessionHistoryEntryOriginalURL + \(URL.newtab.absoluteString) + SessionHistoryEntryTitle + + SessionHistoryEntryShouldOpenExternalURLsPolicyKey + 1 + SessionHistoryEntryData + AAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAEGPVpVAQBgAAAAAAAAAAAP////8AAAAAD2PVpVAQBgD/////AAAAAAAAAAAAAIA/AAAAAP////8= + SessionHistoryEntryURL + \(URL.newtab.absoluteString) + + + SessionHistoryEntryOriginalURL + \(URL.test.absoluteString) + SessionHistoryEntryTitle + test page + SessionHistoryEntryShouldOpenExternalURLsPolicyKey + 1 + SessionHistoryEntryData + AAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAwvZLp1AQBgAAAAAAAAAAAP////8AAAAAwfZLp1AQBgD/////AAAAAAAAAAAAAIA/AAAAAP////8= + SessionHistoryEntryURL + \(URL.test.absoluteString) + + + SessionHistoryEntryOriginalURL + \(URL.alternative.absoluteString) + SessionHistoryEntryTitle + alternative page + SessionHistoryEntryShouldOpenExternalURLsPolicyKey + 1 + SessionHistoryEntryData + AAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAeWCYp1AQBgAAAAAAAAAAAP////8AAAAAeGCYp1AQBgD/////AAAAAAAAAAAAAAAAAAAAAP////8= + SessionHistoryEntryURL + \(URL.alternative.absoluteString) + + + SessionHistoryCurrentIndex + 1 + + RenderTreeSize + 4 + + + """.utf8data + + @MainActor + override func setUp() async throws { + schemeHandler = TestSchemeHandler() + WKWebView.customHandlerSchemes = [.http, .https] + + webViewConfiguration = WKWebViewConfiguration() + // ! uncomment this to view navigation logs + // OSLog.loggingCategories.insert(OSLog.AppCategories.navigation.rawValue) + + // tests return debugDescription instead of localizedDescription + NSError.disableSwizzledDescription = true + + // mock WebView https protocol handling + webViewConfiguration.setURLSchemeHandler(schemeHandler, forURLScheme: URL.NavigationalScheme.https.rawValue) + } + + @MainActor + override func tearDown() async throws { + window?.close() + window = nil + + webViewConfiguration = nil + schemeHandler = nil + WKWebView.customHandlerSchemes = [] + + NSError.disableSwizzledDescription = false + } + + func testWhenPageFailsToLoad_errorPageShown() async throws { + // open Tab with newtab page + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let eNewtabPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + // navigate to test url, fail with error + schemeHandler.middleware = [{ _ in + .failure(NSError.hostNotFound) + }] + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + let error = try await eNavigationFailed.value + _=try await eNavigationFinished.value + + XCTAssertEqual(error.errorCode, NSError.hostNotFound.code) + XCTAssertEqual(error.localizedDescription, NSError.hostNotFound.localizedDescription) + let headerText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-header')[0].innerText") + let errorDescr: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-description')[0].innerText") + + XCTAssertNil(tab.title) + XCTAssertEqual(tabViewModel.title, UserText.tabErrorTitle) + XCTAssertEqual(headerText?.trimmingWhitespace(), UserText.errorPageHeader) + XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.hostNotFound.localizedDescription) + XCTAssertTrue(tab.canGoBack) + XCTAssertFalse(tab.canGoForward) + XCTAssertTrue(tab.canReload) + XCTAssertFalse(viewModel.tabViewModel(at: 0)!.canSaveContent) + XCTAssertEqual(tab.backHistoryItems.count, 1) + XCTAssertEqual(tab.backHistoryItems.first?.url, .newtab) + XCTAssertNil(tab.currentHistoryItem?.title) + XCTAssertEqual(tab.currentHistoryItem?.url, .test) + XCTAssertEqual(tab.content.userEditableUrl, .test) + } + + func testWhenTabWithNoConnectionErrorActivated_reloadTriggered() async throws { + // open 2 Tabs with newtab page + let tab1 = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let tab2 = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let tabsViewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab1, tab2])) + window = WindowsManager.openNewWindow(with: tabsViewModel)! + + // wait until Home page loads + let eNewtabPageLoaded = tab1.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + // navigate to a failing url + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + tab1.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + // wait for error page to open + let eNavigationFailed = tab1.$error.compactMap { $0 }.timeout(5).first().promise() + + _=try await eNavigationFailed.value + + // switch to tab 2 + tabsViewModel.select(at: .unpinned(1)) + + // next load should be ok + let eServerQueried = expectation(description: "server request sent") + schemeHandler.middleware = [{ _ in + eServerQueried.fulfill() + return .ok(.html(Self.testHtml)) + }] + // coming back to the failing tab 1 should trigger its reload + let eNavigationSucceeded = tab1.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tabsViewModel.select(at: .unpinned(0)) + + _=try await eNavigationSucceeded.value + await fulfillment(of: [eServerQueried], timeout: 1) + XCTAssertEqual(tab1.content.url, .test) + XCTAssertNil(tab1.error) + } + + func testWhenTabWithConnectionLostErrorActivatedAndReloadFailsAgain_errorPageIsShownOnce() async throws { + // open 2 Tabs with newtab page + // navigate to a failing url right away + schemeHandler.middleware = [{ _ in + .failure(NSError.connectionLost) + }] + let tab1 = Tab(content: .url(.test, source: .link), webViewConfiguration: webViewConfiguration) + let tab2 = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let tabsViewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab1, tab2])) + window = WindowsManager.openNewWindow(with: tabsViewModel)! + + // wait for error page to open + let eNavigationFailed = tab1.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished = tab1.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + _=try await eNavigationFailed.value + _=try await eNavigationFinished.value + + // switch to tab 2 + tabsViewModel.select(at: .unpinned(1)) + + // coming back to the failing tab 1 should trigger its reload but it will fail again + let eServerQueried = expectation(description: "server request sent") + schemeHandler.middleware = [{ _ in + eServerQueried.fulfill() + return .failure(NSError.noConnection) + }] + let eNavigationFailed2 = tab1.$error.compactMap { $0 }.filter { + $0.errorCode == NSError.noConnection.code + }.timeout(5).first().promise() + + tabsViewModel.select(at: .unpinned(0)) + + await fulfillment(of: [eServerQueried], timeout: 1) + let error = try await eNavigationFailed2.value + + let c = tab1.$isLoading.dropFirst().sink { isLoading in + XCTFail("Failing tab shouldn‘t reload again (isLoading: \(isLoading))") + } + + XCTAssertEqual(error.errorCode, NSError.noConnection.code) + XCTAssertEqual(error.localizedDescription, NSError.noConnection.localizedDescription) + let headerText: String? = try await tab1.webView.evaluateJavaScript("document.getElementsByClassName('error-header')[0].innerText") + let errorDescr: String? = try await tab1.webView.evaluateJavaScript("document.getElementsByClassName('error-description')[0].innerText") + XCTAssertNil(tab1.title) + XCTAssertEqual(tabsViewModel.tabViewModel(at: 0)?.title, UserText.tabErrorTitle) + XCTAssertEqual(headerText?.trimmingWhitespace(), UserText.errorPageHeader) + XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.noConnection.localizedDescription) + + try await Task.sleep(interval: 0.4) // sleep a little to confirm no more navigations are performed + withExtendedLifetime(c) {} + } + + func testWhenTabWithOtherErrorActivated_reloadNotTriggered() async throws { + // open 2 Tabs with newtab page + // navigate to a failing url right away + schemeHandler.middleware = [{ _ in + .failure(NSError.hostNotFound) + }] + let tab1 = Tab(content: .url(.test, source: .link), webViewConfiguration: webViewConfiguration) + let tab2 = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let tabsViewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab1, tab2])) + window = WindowsManager.openNewWindow(with: tabsViewModel)! + + // wait for error page to open + let eNavigationFailed = tab1.$error.compactMap { $0 }.timeout(5).first().promise() + let errorNavigationFinished = tab1.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + _=try await eNavigationFailed.value + _=try await errorNavigationFinished.value + + // switch to tab 2 + tabsViewModel.select(at: .unpinned(1)) + + // coming back to the failing tab 1 should not trigger reload + let c = tab1.$isLoading.filter { $0 == true }.sink { isLoading in + XCTFail("Failing tab shouldn‘t reload again (isLoading: \(isLoading))") + } + tabsViewModel.select(at: .unpinned(0)) + + try await Task.sleep(interval: 0.4) // sleep a little to confirm no more navigations are performed + withExtendedLifetime(c) {} + } + + func testWhenGoingBackToFailingPage_reloadIsTriggered() async throws { + // open Tab with newtab page + // navigate to a failing url right away + schemeHandler.middleware = [{ _ in + .failure(NSError.hostNotFound) + }] + let tab = Tab(content: .url(.test, source: .link), webViewConfiguration: webViewConfiguration) + window = WindowsManager.openNewWindow(with: tab)! + + // wait for navigation to fail + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let errorNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + _=try await eNavigationFailed.value + _=try await errorNavigationFinished.value + + let ePageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + // navigate to test url: success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.alternativeHtml)) + }] + tab.setContent(.url(.alternative, source: .userEntered(URL.test.absoluteString))) + + try await ePageLoaded.value + + // navigate back to failing page: success + let eBackPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + let eRequestSent = expectation(description: "request sent") + schemeHandler.middleware = [{ _ in + eRequestSent.fulfill() + return .ok(.html(Self.testHtml)) + }] + tab.goBack() + try await eBackPageLoaded.value + await fulfillment(of: [eRequestSent]) + + let titleText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByTagName('title')[0].innerText") + XCTAssertEqual(titleText, Self.pageTitle) + XCTAssertEqual(tab.title, titleText) + XCTAssertEqual(tab.currentHistoryItem?.url, .test) + XCTAssertEqual(tab.currentHistoryItem?.title, titleText) + + XCTAssertEqual(tab.backHistoryItems.count, 0) + XCTAssertFalse(tab.canGoBack) + + XCTAssertEqual(tab.forwardHistoryItems.count, 1) + XCTAssertEqual(tab.forwardHistoryItems.first?.url, .alternative) + XCTAssertEqual(tab.forwardHistoryItems.first?.title, Self.alternativeTitle) + XCTAssertTrue(tab.canGoForward) + + XCTAssertTrue(tab.canReload) + } + + func testWhenGoingBackToFailingPageAndItFailsAgain_errorPageIsUpdated() async throws { + // open Tab with newtab page + // navigate to a failing url right away + schemeHandler.middleware = [{ _ in + .failure(NSError.hostNotFound) + }] + let tab = Tab(content: .url(.test, source: .link), webViewConfiguration: webViewConfiguration) + window = WindowsManager.openNewWindow(with: tab)! + + // wait for navigation to fail + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let errorNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + _=try await eNavigationFailed.value + _=try await errorNavigationFinished.value + + let ePageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + // navigate to test url: success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.alternativeHtml)) + }] + tab.setContent(.url(.alternative, source: .userEntered(URL.test.absoluteString))) + + try await ePageLoaded.value + + // navigate back to failing page: failure + let eBackPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + let eNavigationFailed2 = tab.$error.compactMap { $0 }.filter { + $0.errorCode == NSError.noConnection.code + }.timeout(5).first().promise() + + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + tab.goBack() + _=try await eNavigationFailed2.value + _=try await eBackPageLoaded.value + + let headerText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-header')[0].innerText") + let errorDescr: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-description')[0].innerText") + XCTAssertNil(tab.title) + XCTAssertEqual(tabViewModel.title, UserText.tabErrorTitle) + XCTAssertEqual(headerText?.trimmingWhitespace(), UserText.errorPageHeader) + XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.noConnection.localizedDescription) + + XCTAssertEqual(tab.currentHistoryItem?.url, .test) + XCTAssertNil(tab.currentHistoryItem?.title) + + XCTAssertEqual(tab.backHistoryItems.count, 0) + XCTAssertFalse(tab.canGoBack) + + XCTAssertEqual(tab.forwardHistoryItems.count, 1) + XCTAssertEqual(tab.forwardHistoryItems.first?.url, .alternative) + XCTAssertEqual(tab.forwardHistoryItems.first?.title, Self.alternativeTitle) + XCTAssertTrue(tab.canGoForward) + + XCTAssertTrue(tab.canReload) + } + + func testWhenPageLoadedAndFailsOnRefreshAndOnConsequentRefresh_errorPageIsUpdatedKeepingForwardHistory() async throws { + // open Tab with newtab page + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let eNewtabPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + // navigate to test url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + let eNavigationFinished2 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFinished2.value + + // navigate to another url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.alternativeHtml)) + }] + let eNavigationFinished3 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.alternative, source: .userEntered(URL.alternative.absoluteString))) + _=try await eNavigationFinished3.value + + // navigate back + let eBackNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.goBack() + _=try await eBackNavigationFinished.value + + // refresh: fail + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished4 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.reload() + _=try await eNavigationFailed.value + _=try await eNavigationFinished4.value + + // refresh again: fail + let eServerQueried = expectation(description: "server request sent") + schemeHandler.middleware = [{ _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + eServerQueried.fulfill() + } + return .failure(NSError.connectionLost) + }] + let eNavigationFailed2 = tab.$error.compactMap { $0 }.timeout(5).first().promise() + + tab.reload() + _=try await eNavigationFailed2.value + await fulfillment(of: [eServerQueried]) + + let headerText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-header')[0].innerText") + let errorDescr: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-description')[0].innerText") + XCTAssertNil(tab.title) + XCTAssertEqual(tabViewModel.title, UserText.tabErrorTitle) + XCTAssertEqual(headerText?.trimmingWhitespace(), UserText.errorPageHeader) + XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.connectionLost.localizedDescription) + + XCTAssertEqual(tab.currentHistoryItem?.url, .test) + XCTAssertEqual(tab.currentHistoryItem?.title, URL.test.host) + + XCTAssertEqual(tab.backHistoryItems.count, 1) + XCTAssertEqual(tab.backHistoryItems.first?.url, .newtab, "url") + XCTAssertTrue(tab.canGoBack) + + XCTAssertEqual(tab.forwardHistoryItems.count, 1) + XCTAssertEqual(tab.forwardHistoryItems.first?.url, .alternative, "url") + XCTAssertEqual(tab.forwardHistoryItems.first?.title, Self.alternativeTitle, "title") + XCTAssertTrue(tab.canGoForward) + + XCTAssertTrue(tab.canReload) + } + + func testWhenPageLoadedAndFailsOnRefreshAndSucceedsOnConsequentRefresh_forwardHistoryIsPreserved() async throws { + // open Tab with newtab page + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let eNewtabPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + // navigate to test url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + let eNavigationFinished2 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFinished2.value + + // navigate to another url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.alternativeHtml)) + }] + let eNavigationFinished3 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.alternative, source: .userEntered(URL.alternative.absoluteString))) + _=try await eNavigationFinished3.value + + // navigate back + let eBackNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.goBack() + _=try await eBackNavigationFinished.value + + // refresh: fail + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished4 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.reload() + _=try await eNavigationFailed.value + _=try await eNavigationFinished4.value + + // refresh again: success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + let eNavigationFinished5 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.reload() + _=try await eNavigationFinished5.value + + let titleText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByTagName('title')[0].innerText") + XCTAssertEqual(tab.title, Self.pageTitle) + XCTAssertEqual(titleText?.trimmingWhitespace(), tab.title) + + XCTAssertEqual(tab.currentHistoryItem?.url, .test) + XCTAssertEqual(tab.currentHistoryItem?.title, Self.pageTitle) + + XCTAssertEqual(tab.backHistoryItems.count, 1) + XCTAssertEqual(tab.backHistoryItems.first?.url, .newtab) + XCTAssertTrue(tab.canGoBack) + + XCTAssertEqual(tab.forwardHistoryItems.count, 1) + XCTAssertEqual(tab.forwardHistoryItems.first?.url, .alternative) + XCTAssertEqual(tab.forwardHistoryItems.first?.title, Self.alternativeTitle) + XCTAssertTrue(tab.canGoForward) + + XCTAssertTrue(tab.canReload) + } + + func testWhenReloadingBySubmittingSameURL_errorPageRemainsSame() async throws { + // open Tab with newtab page + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let eNewtabPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + // navigate to test url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + let eNavigationFinished2 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFinished2.value + + // navigate to another url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.alternativeHtml)) + }] + let eNavigationFinished3 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.alternative, source: .userEntered(URL.alternative.absoluteString))) + _=try await eNavigationFinished3.value + + // navigate back + let eBackNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.goBack() + _=try await eBackNavigationFinished.value + + // refresh: fail + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished4 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFailed.value + _=try await eNavigationFinished4.value + + // refresh again: fail + let eServerQueried = expectation(description: "server request sent") + schemeHandler.middleware = [{ _ in + eServerQueried.fulfill() + return .failure(NSError.connectionLost) + }] + let eNavigationFailed2 = tab.$error.compactMap { $0 }.timeout(5).first().promise() + + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFailed2.value + await fulfillment(of: [eServerQueried]) + + let headerText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-header')[0].innerText") + let errorDescr: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-description')[0].innerText") + XCTAssertNil(tab.title) + XCTAssertEqual(tabViewModel.title, UserText.tabErrorTitle) + XCTAssertEqual(headerText?.trimmingWhitespace(), UserText.errorPageHeader) + XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.connectionLost.localizedDescription) + + XCTAssertEqual(tab.currentHistoryItem?.url, .test) + XCTAssertEqual(tab.currentHistoryItem?.title, URL.test.host) + + XCTAssertEqual(tab.backHistoryItems.count, 1) + XCTAssertEqual(tab.backHistoryItems.first?.url, .newtab, "url") + XCTAssertNil(tab.backHistoryItems.first?.title, "title") + XCTAssertTrue(tab.canGoBack) + + XCTAssertEqual(tab.forwardHistoryItems.count, 1) + XCTAssertEqual(tab.forwardHistoryItems.first?.url, .alternative, "url") + XCTAssertEqual(tab.forwardHistoryItems.first?.title, Self.alternativeTitle, "title") + XCTAssertTrue(tab.canGoForward) + + XCTAssertTrue(tab.canReload) + } + + func testWhenGoingToAnotherUrlFails_newBackForwardHistoryItemIsAdded() async throws { + // open Tab with newtab page + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let eNewtabPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + // navigate to test url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + let eNavigationFinished2 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFinished2.value + + // navigate to another url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.alternativeHtml)) + }] + let eNavigationFinished3 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.alternative, source: .userEntered(URL.alternative.absoluteString))) + _=try await eNavigationFinished3.value + + // navigate back + let eBackNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.goBack() + _=try await eBackNavigationFinished.value + + // refresh: fail + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished4 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFailed.value + _=try await eNavigationFinished4.value + + // go to another url: fail + let eServerQueried = expectation(description: "server request sent") + schemeHandler.middleware = [{ _ in + eServerQueried.fulfill() + return .failure(NSError.connectionLost) + }] + let eNavigationFailed2 = tab.$error.compactMap { $0 }.timeout(5).first().promise() + + tab.setContent(.url(.alternative, source: .userEntered(URL.alternative.absoluteString))) + _=try await eNavigationFailed2.value + await fulfillment(of: [eServerQueried]) + + let headerText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-header')[0].innerText") + let errorDescr: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-description')[0].innerText") + XCTAssertNil(tab.title) + XCTAssertEqual(tabViewModel.title, UserText.tabErrorTitle) + XCTAssertEqual(headerText?.trimmingWhitespace(), UserText.errorPageHeader) + XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.connectionLost.localizedDescription) + + XCTAssertEqual(tab.currentHistoryItem?.url, .alternative) + XCTAssertNil(tab.currentHistoryItem?.title) + + XCTAssertEqual(tab.backHistoryItems.count, 2) + XCTAssertEqual(tab.backHistoryItems[safe: 0]?.url, .newtab) + XCTAssertNil(tab.backHistoryItems[safe: 0]?.title) + XCTAssertEqual(tab.backHistoryItems[safe: 1]?.url, .test) + XCTAssertEqual(tab.backHistoryItems[safe: 1]?.title, Self.pageTitle) + XCTAssertTrue(tab.canGoBack) + + XCTAssertEqual(tab.forwardHistoryItems.count, 0) + XCTAssertFalse(tab.canGoForward) + + XCTAssertTrue(tab.canReload) + } + + func testWhenGoingToAnotherUrlSucceeds_newBackForwardHistoryItemIsAdded() async throws { + // open Tab with newtab page + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let eNewtabPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + // navigate to test url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + let eNavigationFinished2 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFinished2.value + + // navigate to another url, success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.alternativeHtml)) + }] + let eNavigationFinished3 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.setContent(.url(.alternative, source: .userEntered(URL.alternative.absoluteString))) + _=try await eNavigationFinished3.value + + // navigate back + let eBackNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.goBack() + _=try await eBackNavigationFinished.value + + // refresh: fail + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished4 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.setContent(.url(.test, source: .userEntered(URL.test.absoluteString))) + _=try await eNavigationFailed.value + _=try await eNavigationFinished4.value + + // go to another url: success + schemeHandler.middleware = [{ _ in + .ok(.html(Self.alternativeHtml)) + }] + let eNavigationFinished5 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.setContent(.url(.alternative, source: .userEntered(URL.alternative.absoluteString))) + _=try await eNavigationFinished5.value + + let titleText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByTagName('title')[0].innerText") + XCTAssertEqual(tab.title, Self.alternativeTitle) + XCTAssertEqual(titleText?.trimmingWhitespace(), tab.title) + + XCTAssertEqual(tab.currentHistoryItem?.url, .alternative) + XCTAssertEqual(tab.currentHistoryItem?.title, Self.alternativeTitle) + + XCTAssertEqual(tab.backHistoryItems.count, 2) + XCTAssertEqual(tab.backHistoryItems[safe: 0]?.url, .newtab) + XCTAssertNil(tab.backHistoryItems[safe: 0]?.title) + XCTAssertEqual(tab.backHistoryItems[safe: 1]?.url, .test) + XCTAssertEqual(tab.backHistoryItems[safe: 1]?.title, Self.pageTitle) + XCTAssertTrue(tab.canGoBack) + + XCTAssertEqual(tab.forwardHistoryItems.count, 0) + XCTAssertFalse(tab.canGoForward) + + XCTAssertTrue(tab.canReload) + } + + func testWhenLoadingFailsAfterSessionRestoration_navigationHistoryIsPreserved() async throws { + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + + let tab = Tab(content: .url(.test, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration, interactionStateData: Self.sessionStateData) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let eNewtabPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + XCTAssertTrue(tab.canReload) + + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + // open new tab + viewModel.appendNewTab() + + // select the failing tab triggering its reload + let eReloadFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + viewModel.select(at: .unpinned(0)) + _=try await eReloadFinished.value + + XCTAssertEqual(tab.currentHistoryItem?.url, .test) + XCTAssertEqual(tab.currentHistoryItem?.title, Self.pageTitle) + + XCTAssertEqual(tab.backHistoryItems.count, 1) + XCTAssertEqual(tab.backHistoryItems.first?.url, .newtab) + XCTAssertEqual(tab.backHistoryItems.first?.title ?? "", "") + XCTAssertTrue(tab.canGoBack) + + XCTAssertEqual(tab.forwardHistoryItems.count, 1) + XCTAssertEqual(tab.forwardHistoryItems.first?.url, .alternative) + XCTAssertEqual(tab.forwardHistoryItems.first?.title, Self.alternativeTitle) + XCTAssertTrue(tab.canGoForward) + + XCTAssertTrue(tab.canReload) + } + + func testPinnedTabDoesNotNavigateAway() async throws { + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + + let tab = Tab(content: .url(.alternative, source: .ui), webViewConfiguration: webViewConfiguration) + let manager = PinnedTabsManager() + manager.pin(tab) + + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: []), pinnedTabsManager: manager) + window = WindowsManager.openNewWindow(with: viewModel)! + viewModel.select(at: .pinned(0)) + + // wait for tab to load + let eNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + _=try await eNavigationFinished.value + + // refresh: fail + schemeHandler.middleware = [{ _ in + .failure(NSError.noConnection) + }] + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished2 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.reload() + _=try await eNavigationFailed.value + _=try await eNavigationFinished2.value + + XCTAssertNotNil(tab.error) + + schemeHandler.middleware = [{ _ in + .ok(.html(Self.testHtml)) + }] + let eNavigationFinished5 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + tab.reload() + _=try await eNavigationFinished5.value + + XCTAssertNil(tab.error) + XCTAssertEqual(viewModel.tabs.count, 1) + } + + func testWhenPageFailsToLoadAfterRedirect_errorPageShown() async throws { + // open Tab with newtab page + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let eNewtabPageLoaded = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + try await eNewtabPageLoaded.value + + // navigate to alt url, redirect to test url, fail with error + schemeHandler.middleware = [{ request in + .init { task in + let response = URLResponse(url: request.url!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) + let newRequest = URLRequest(url: .test) + task._didPerformRedirection(response, newRequest: newRequest) + + task.didFailWithError(NSError.hostNotFound) + } + }] + tab.setContent(.url(.test, source: .userEntered(URL.alternative.absoluteString))) + + let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() + let eNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + let error = try await eNavigationFailed.value + _=try await eNavigationFinished.value + + XCTAssertEqual(error.errorCode, NSError.hostNotFound.code) + XCTAssertEqual(error.localizedDescription, NSError.hostNotFound.localizedDescription) + let headerText: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-header')[0].innerText") + let errorDescr: String? = try await tab.webView.evaluateJavaScript("document.getElementsByClassName('error-description')[0].innerText") + + XCTAssertNil(tab.title) + XCTAssertEqual(tabViewModel.title, UserText.tabErrorTitle) + XCTAssertEqual(headerText?.trimmingWhitespace(), UserText.errorPageHeader) + XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.hostNotFound.localizedDescription) + XCTAssertTrue(tab.canGoBack) + XCTAssertFalse(tab.canGoForward) + XCTAssertTrue(tab.canReload) + XCTAssertFalse(viewModel.tabViewModel(at: 0)!.canSaveContent) + XCTAssertEqual(tab.backHistoryItems.count, 1) + XCTAssertEqual(tab.backHistoryItems.first?.url, .newtab) + XCTAssertNil(tab.currentHistoryItem?.title) + XCTAssertEqual(tab.currentHistoryItem?.url, .test) + XCTAssertEqual(tab.content.userEditableUrl, .test) + } + +} + +private extension URL { + static let test = URL(string: "https://test.com/")! + static let alternative = URL(string: "https://alternative.com/")! +} + +private extension NSError { + + static let hostNotFound: NSError = { + let errorCode = -1003 + let errorDescription = "hostname not found" + let wkError = NSError(domain: NSURLErrorDomain, code: errorCode, userInfo: [NSLocalizedDescriptionKey: errorDescription]) + return wkError + }() + + static let noConnection: NSError = { + let errorDescription = "no internet connection" + return URLError(.notConnectedToInternet, userInfo: [NSLocalizedDescriptionKey: errorDescription]) as NSError + }() + + static let connectionLost: NSError = { + let errorDescription = "connection lost" + return URLError(.networkConnectionLost, userInfo: [NSLocalizedDescriptionKey: errorDescription]) as NSError + }() + +} + +extension Data { + + static let sessionRestorationMagic = Data([0x00, 0x00, 0x00, 0x02]) + +} diff --git a/IntegrationTests/TabExtensions/SearchNonexistentDomainTests.swift b/IntegrationTests/Tab/SearchNonexistentDomainTests.swift similarity index 77% rename from IntegrationTests/TabExtensions/SearchNonexistentDomainTests.swift rename to IntegrationTests/Tab/SearchNonexistentDomainTests.swift index 778ee77770..346231ede7 100644 --- a/IntegrationTests/TabExtensions/SearchNonexistentDomainTests.swift +++ b/IntegrationTests/Tab/SearchNonexistentDomainTests.swift @@ -92,28 +92,27 @@ final class SearchNonexistentDomainTests: XCTestCase { let tab = Tab(content: .none, webViewConfiguration: webViewConfiguration, privacyFeatures: privacyFeaturesMock) window = WindowsManager.openNewWindow(with: tab)! - let eRedirected = Future { promise in - self.schemeHandler.middleware = [{ request in - if request.url!.isDuckDuckGoSearch { - promise(.success(request.url!)) - return .ok(.html("")) - } else { - return .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) - } - }] - }.timeout(3).first().promise() - let url = urls.invalidTLD let enteredString = url.absoluteString.dropping(prefix: url.navigationalScheme!.separated()) + let eRedirected = expectation(description: "Redirected to SERP") + self.schemeHandler.middleware = [{ request in + if request.url!.isDuckDuckGoSearch { + XCTAssertEqual(request.url, URL.makeSearchUrl(from: enteredString)) + eRedirected.fulfill() + return .ok(.html("")) + } else { + return .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) + } + }] + addressBar.makeMeFirstResponder() addressBar.stringValue = enteredString NSApp.swizzled_currentEvent = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: Date().timeIntervalSinceReferenceDate, windowNumber: 0, context: nil, characters: "\n", charactersIgnoringModifiers: "", isARepeat: false, keyCode: UInt16(kVK_Return))! _=addressBar.control(addressBar, textView: addressBar.currentEditor() as! NSTextView, doCommandBy: #selector(NSResponder.insertNewline)) - let redirectUrl = try await eRedirected.value - XCTAssertEqual(redirectUrl, URL.makeSearchUrl(from: enteredString)) + await fulfillment(of: [eRedirected], timeout: 3) } @MainActor @@ -121,14 +120,20 @@ final class SearchNonexistentDomainTests: XCTestCase { let tab = Tab(content: .none, webViewConfiguration: webViewConfiguration, privacyFeatures: privacyFeaturesMock) window = WindowsManager.openNewWindow(with: tab)! - self.schemeHandler.middleware = [{ _ in - .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) + self.schemeHandler.middleware = [{ request in + XCTAssertFalse(request.url!.isDuckDuckGoSearch) + return .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) }] let eNavigationFailed = tab.$error .compactMap { $0 } .timeout(3) .first() .promise() + // error page navigation + let eNavigationDidFinish = tab.webViewDidFinishNavigationPublisher + .timeout(3) + .first() + .promise() let url = urls.validTLD let enteredString = url.absoluteString.dropping(prefix: url.navigationalScheme!.separated()) @@ -140,6 +145,7 @@ final class SearchNonexistentDomainTests: XCTestCase { _=addressBar.control(addressBar, textView: addressBar.currentEditor() as! NSTextView, doCommandBy: #selector(NSResponder.insertNewline)) let error = try await eNavigationFailed.value + _=try await eNavigationDidFinish.value XCTAssertEqual(error.errorCode, NSURLErrorCannotFindHost) } @@ -148,14 +154,20 @@ final class SearchNonexistentDomainTests: XCTestCase { let tab = Tab(content: .none, webViewConfiguration: webViewConfiguration, privacyFeatures: privacyFeaturesMock) window = WindowsManager.openNewWindow(with: tab)! - self.schemeHandler.middleware = [{ _ in - .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) + self.schemeHandler.middleware = [{ request in + XCTAssertFalse(request.url!.isDuckDuckGoSearch) + return .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) }] let eNavigationFailed = tab.$error .compactMap { $0 } .timeout(3) .first() .promise() + // error page navigation + let eNavigationDidFinish = tab.webViewDidFinishNavigationPublisher + .timeout(3) + .first() + .promise() let url = urls.invalidTLD let enteredString = url.absoluteString @@ -167,6 +179,7 @@ final class SearchNonexistentDomainTests: XCTestCase { _=addressBar.control(addressBar, textView: addressBar.currentEditor() as! NSTextView, doCommandBy: #selector(NSResponder.insertNewline)) let error = try await eNavigationFailed.value + _=try await eNavigationDidFinish.value XCTAssertEqual(error.errorCode, NSURLErrorCannotFindHost) } @@ -175,11 +188,17 @@ final class SearchNonexistentDomainTests: XCTestCase { let tab = Tab(content: .none, webViewConfiguration: webViewConfiguration, privacyFeatures: privacyFeaturesMock) window = WindowsManager.openNewWindow(with: tab)! - self.schemeHandler.middleware = [{ _ in - .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) + self.schemeHandler.middleware = [{ request in + XCTAssertFalse(request.url!.isDuckDuckGoSearch) + return .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) }] let eNavigationFailed = tab.$error .compactMap { $0 } + .timeout(3) + .first() + .promise() + // error page navigation + let eNavigationDidFinish = tab.webViewDidFinishNavigationPublisher .timeout(10) .first() .promise() @@ -189,6 +208,7 @@ final class SearchNonexistentDomainTests: XCTestCase { tab.setUrl(url, source: .link) let error = try await eNavigationFailed.value + _=try await eNavigationDidFinish.value XCTAssertEqual(error.errorCode, NSURLErrorCannotFindHost) } @@ -197,20 +217,21 @@ final class SearchNonexistentDomainTests: XCTestCase { let tab = Tab(content: .none, webViewConfiguration: webViewConfiguration, privacyFeatures: privacyFeaturesMock) window = WindowsManager.openNewWindow(with: tab)! - let eRedirected = Future { promise in - self.schemeHandler.middleware = [{ request in - if request.url!.isDuckDuckGoSearch { - promise(.success(request.url!)) - return .ok(.html("")) - } else { - return .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) - } - }] - }.timeout(3).first().promise() - let url = urls.invalidTLD let enteredString = url.absoluteString.dropping(prefix: url.navigationalScheme!.separated()) + let eRedirected = expectation(description: "Redirected to SERP") + self.schemeHandler.middleware = [{ request in + if request.url!.isDuckDuckGoSearch { + XCTAssertEqual(request.url, URL.makeSearchUrl(from: enteredString)) + eRedirected.fulfill() + + return .ok(.html("")) + } else { + return .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost)) + } + }] + addressBar.makeMeFirstResponder() addressBar.stringValue = enteredString @@ -223,8 +244,7 @@ final class SearchNonexistentDomainTests: XCTestCase { addressBar.suggestionViewControllerDidConfirmSelection(addressBar.suggestionViewController) - let redirectUrl = try await eRedirected.value - XCTAssertEqual(redirectUrl, URL.makeSearchUrl(from: enteredString)) + await fulfillment(of: [eRedirected], timeout: 3) } } diff --git a/UnitTests/Common/Extensions/StringExtensionTests.swift b/UnitTests/Common/Extensions/StringExtensionTests.swift new file mode 100644 index 0000000000..76a5b0fd58 --- /dev/null +++ b/UnitTests/Common/Extensions/StringExtensionTests.swift @@ -0,0 +1,44 @@ +// +// StringExtensionTests.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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +class StringExtensionTests: XCTestCase { + + func testHtmlEscapedString() { + NSError.disableSwizzledDescription = true + defer { NSError.disableSwizzledDescription = false } + + XCTAssertEqual("\"DuckDuckGo\"®".escapedUnicodeHtmlString(), ""DuckDuckGo"®") + XCTAssertEqual("i don‘t want to 'sleep'™".escapedUnicodeHtmlString(), "i don‘t want to 'sleep'™") + XCTAssertEqual("{ $embraced [&text]}".escapedUnicodeHtmlString(), "{ $embraced [&text]}") + XCTAssertEqual("X ^ 2 + y / 2 = 4 < 6%".escapedUnicodeHtmlString(), "X ^ 2 + y / 2 = 4 < 6%") + XCTAssertEqual("".escapedUnicodeHtmlString(), "<some&tag>") + XCTAssertEqual("© “text” with «emojis» 🩷🦆".escapedUnicodeHtmlString(), "© “text” with «emojis» 🩷🦆") + XCTAssertEqual("`my.mail@duck.com`".escapedUnicodeHtmlString(), "amy.mail@duck.coma") + XCTAssertEqual("floop!burp".escapedUnicodeHtmlString(), + "<hey beep=\"#test\" boop='#' fool=1 >floop!<b>burp</b></hey>") + + XCTAssertEqual(URLError(URLError.Code.cannotConnectToHost, userInfo: [NSLocalizedDescriptionKey: "Could not connect to the server."]).localizedDescription.escapedUnicodeHtmlString(), "Could not connect to the server.") + XCTAssertEqual(URLError(URLError.Code.cannotConnectToHost).localizedDescription.escapedUnicodeHtmlString(), "The operation couldn’t be completed. (NSURLErrorDomain error -1004.)") + XCTAssertEqual(URLError(URLError.Code.cannotFindHost).localizedDescription.escapedUnicodeHtmlString(), "The operation couldn’t be completed. (NSURLErrorDomain error -1003.)") + } + +} diff --git a/UnitTests/Common/NSErrorAdditionalInfo.swift b/UnitTests/Common/NSErrorAdditionalInfo.swift index 7b8e6e0a31..6f861ae629 100644 --- a/UnitTests/Common/NSErrorAdditionalInfo.swift +++ b/UnitTests/Common/NSErrorAdditionalInfo.swift @@ -27,8 +27,17 @@ extension NSError { method_exchangeImplementations(originalLocalizedDescription, swizzledLocalizedDescription) }() - @objc func swizzledLocalizedDescription() -> String { - self.debugDescription + // use `NSError.disableSwizzledDescription = true` to return an original localizedDescription, don‘t forget to set it back in tearDown + @objc dynamic func swizzledLocalizedDescription() -> String { + if Self.disableSwizzledDescription { + self.swizzledLocalizedDescription() // return original + } else { + self.debugDescription + " – NSErrorAdditionalInfo.swift" + } } + private static let disableSwizzledDescriptionKey = UnsafeRawPointer(bitPattern: "disableSwizzledDescriptionKey".hashValue)! + + static var disableSwizzledDescription: Bool = false + } diff --git a/UnitTests/Common/TestsBridging.h b/UnitTests/Common/TestsBridging.h index 2d5e4175c3..b2184c97d8 100644 --- a/UnitTests/Common/TestsBridging.h +++ b/UnitTests/Common/TestsBridging.h @@ -19,3 +19,4 @@ #import "Bridging.h" #import "DownloadsWebViewMock.h" +#import "WKURLSchemeTask+Private.h" diff --git a/UnitTests/Common/WKURLSchemeTask+Private.h b/UnitTests/Common/WKURLSchemeTask+Private.h new file mode 100644 index 0000000000..2264f27d76 --- /dev/null +++ b/UnitTests/Common/WKURLSchemeTask+Private.h @@ -0,0 +1,30 @@ +// +// WKURLSchemeTask+Private.h +// +// 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 + +NS_ASSUME_NONNULL_BEGIN + +@protocol WKURLSchemeTaskPrivate + +- (void)_willPerformRedirection:(NSURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest *))completionHandler; +- (void)_didPerformRedirection:(NSURLResponse *)response newRequest:(NSURLRequest *)request; + +@end + +NS_ASSUME_NONNULL_END diff --git a/UnitTests/Common/WKWebViewMockingExtension.swift b/UnitTests/Common/WKWebViewMockingExtension.swift index aa5e512c39..2824714d35 100644 --- a/UnitTests/Common/WKWebViewMockingExtension.swift +++ b/UnitTests/Common/WKWebViewMockingExtension.swift @@ -53,7 +53,7 @@ class TestSchemeHandler: NSObject, WKURLSchemeHandler { func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { for middleware in middleware { if let handler = middleware(urlSchemeTask.request) { - handler(urlSchemeTask) + handler(urlSchemeTask as! WKURLSchemeTaskPrivate) return } } @@ -103,12 +103,12 @@ struct WKURLSchemeTaskHandler { } } - let handler: (WKURLSchemeTask) -> Void - init(handler: @escaping (WKURLSchemeTask) -> Void) { + let handler: (WKURLSchemeTaskPrivate) -> Void + init(handler: @escaping (WKURLSchemeTaskPrivate) -> Void) { self.handler = handler } - func callAsFunction(_ task: WKURLSchemeTask) { + func callAsFunction(_ task: WKURLSchemeTaskPrivate) { handler(task) } diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift index 147784dcb3..6d698185e4 100644 --- a/UnitTests/DataImport/DataImportViewModelTests.swift +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -36,6 +36,7 @@ import XCTest model = nil importTask = nil openPanelCallback = nil + NSError.disableSwizzledDescription = false } // MARK: - Tests @@ -1514,6 +1515,8 @@ import XCTest // MARK: - Feedback func testFeedbackSending() { + NSError.disableSwizzledDescription = true + let summary: [DataImportViewModel.DataTypeImportResult] = [ .init(.bookmarks, .success(.empty)), .init(.bookmarks, .failure(Failure(.passwords, .dataCorrupted))), diff --git a/UnitTests/Tab/Model/TabTests.swift b/UnitTests/Tab/Model/TabTests.swift index f366cc4ba7..faf05897e4 100644 --- a/UnitTests/Tab/Model/TabTests.swift +++ b/UnitTests/Tab/Model/TabTests.swift @@ -269,8 +269,8 @@ final class TabTests: XCTestCase { XCTAssertTrue(tab.canGoBack) XCTAssertFalse(tab.canGoForward) XCTAssertEqual(tab.webView.url, urls.url3) - XCTAssertEqual(tab.webView.backForwardList.backList.map(\.url), [urls.url]) - XCTAssertEqual(tab.webView.backForwardList.forwardList, []) + XCTAssertEqual(tab.backHistoryItems.map(\.url), [urls.url]) + XCTAssertEqual(tab.forwardHistoryItems, []) withExtendedLifetime((c1, c2)) {} } @@ -344,8 +344,8 @@ final class TabTests: XCTestCase { XCTAssertTrue(tab.canGoBack) XCTAssertFalse(tab.canGoForward) XCTAssertEqual(tab.webView.url, urls.url3) - XCTAssertEqual(tab.webView.backForwardList.backList.map(\.url), [urls.url, urls.url3]) - XCTAssertEqual(tab.webView.backForwardList.forwardList, []) + XCTAssertEqual(tab.backHistoryItems.map(\.url), [urls.url, urls.url3]) + XCTAssertEqual(tab.forwardHistoryItems, []) withExtendedLifetime((c1, c2)) {} } diff --git a/UnitTests/Tab/ViewModel/TabViewModelTests.swift b/UnitTests/Tab/ViewModel/TabViewModelTests.swift index 5f0029ad42..9d14bf585a 100644 --- a/UnitTests/Tab/ViewModel/TabViewModelTests.swift +++ b/UnitTests/Tab/ViewModel/TabViewModelTests.swift @@ -134,18 +134,23 @@ final class TabViewModelTests: XCTestCase { XCTAssertEqual(tabViewModel.title, "New Tab") } - func testWhenTabTitleIsNotNilThenTitleReflectsTabTitle() { + func testWhenTabTitleIsNotNilThenTitleReflectsTabTitle() async throws { let tabViewModel = TabViewModel.forTabWithURL(.duckDuckGo) let testTitle = "Test title" - tabViewModel.tab.title = testTitle let titleExpectation = expectation(description: "Title") - - tabViewModel.$title.debounce(for: 0.1, scheduler: RunLoop.main).sink { title in + tabViewModel.$title.dropFirst().sink { + if case .failure(let error) = $0 { + XCTFail("\(error)") + } + } receiveValue: { title in XCTAssertEqual(title, testTitle) titleExpectation.fulfill() } .store(in: &cancellables) - waitForExpectations(timeout: 1, handler: nil) + + tabViewModel.tab.title = testTitle + + await fulfillment(of: [titleExpectation], timeout: 0.5) } func testWhenTabTitleIsNilThenTitleIsAddressBarString() { @@ -153,7 +158,7 @@ final class TabViewModelTests: XCTestCase { let titleExpectation = expectation(description: "Title") - tabViewModel.$title.debounce(for: 0.1, scheduler: RunLoop.main).sink { title in + tabViewModel.$title.debounce(for: 0.01, scheduler: RunLoop.main).sink { title in XCTAssertEqual(title, URL.duckDuckGo.host!) titleExpectation.fulfill() } .store(in: &cancellables) diff --git a/UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift b/UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift index b5c840aa5d..806c139493 100644 --- a/UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift +++ b/UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift @@ -31,6 +31,10 @@ final class WKWebViewPrivateMethodsAvailabilityTests: XCTestCase { XCTAssertTrue(WKWebView.instancesRespond(to: WKWebView.Selector.fullScreenPlaceholderView)) } + func testWebViewRespondsTo_loadAlternateHTMLString() { + XCTAssertTrue(WKWebView.instancesRespond(to: WKWebView.Selector.loadAlternateHTMLString)) + } + func testWKBackForwardListRespondsTo_removeAllItems() { XCTAssertTrue(WKBackForwardList.instancesRespond(to: WKBackForwardList.removeAllItemsSelector)) } diff --git a/UnitTests/TabBar/ViewModel/TabLazyLoaderTests.swift b/UnitTests/TabBar/ViewModel/TabLazyLoaderTests.swift index d4ea051d82..b14feb90fd 100644 --- a/UnitTests/TabBar/ViewModel/TabLazyLoaderTests.swift +++ b/UnitTests/TabBar/ViewModel/TabLazyLoaderTests.swift @@ -18,6 +18,7 @@ import XCTest import Combine +import Navigation @testable import DuckDuckGo_Privacy_Browser private final class TabMock: LazyLoadable { @@ -32,7 +33,8 @@ private final class TabMock: LazyLoadable { lazy var loadingFinishedPublisher: AnyPublisher = loadingFinishedSubject.eraseToAnyPublisher() func isNewer(than other: TabMock) -> Bool { isNewerClosure(other) } - func reload() { reloadClosure(self) } + @discardableResult + func reload() -> ExpectedNavigation? { reloadClosure(self); return nil } var isNewerClosure: (TabMock) -> Bool = { _ in true } var reloadClosure: (TabMock) -> Void = { _ in }